Compare commits

...

29 Commits

Author SHA1 Message Date
胡旭 58c27f96bf fix: validation schema 添加 coverImage 字段(修复封面图无法保存) 2026-06-25 09:37:08 +08:00
胡旭 5ac3617a9e chore: 添加 .npmrc 允许 Prisma build scripts 2026-06-25 09:10:03 +08:00
胡旭 a4c265d503 chore: 移除 Docker 相关配置 2026-06-25 09:00:04 +08:00
胡旭 159ec69d3d fix: runner 阶段安装 openssl(Prisma 检测需要) 2026-06-25 08:57:40 +08:00
胡旭 df9dee453f fix: runner 阶段重新 prisma generate(匹配 OpenSSL 版本) 2026-06-25 08:51:27 +08:00
胡旭 30823e9926 fix: AiAssistant selectedText 可选链修复 2026-06-25 08:36:27 +08:00
胡旭 5e129fda86 fix: 改用 node:22-slim(Debian),去掉 Alpine openssl 依赖
Alpine 包索引 TLS 错误导致 openssl 安装失败。
Debian slim 镜像自带 OpenSSL,无需额外安装。
同步修改 addgroup/adduser 为 Debian 语法。
2026-06-25 08:31:16 +08:00
胡旭 0268048c62 fix: 去掉 start.sh,CMD 内联启动逻辑(彻底解决权限问题) 2026-06-25 08:21:32 +08:00
huxu 707d065edb feat: AI 写作助手重构 — 右侧面板、选中文本、撤销、生成摘要/标题
- AiAssistant 改为右侧粘性面板,按文本处理/翻译/智能生成分组
- 支持选中文本局部处理(未选中时处理全文)
- 替换/追加内容后支持撤销恢复
- 新增「生成标题」「生成摘要」按钮,结果可一键填入表单
- 编辑器最小高度从 300px 提高到 500px
- admin layout 去除 max-w-5xl 限制,充分利用宽屏空间
- 添加 .env.example 模板,.gitignore 放行
- package.json 添加 @tailwindcss/oxide-win32-x64-msvc optionalDependencies(兼容 Windows)
2026-06-24 20:06:49 +08:00
胡旭 bf76975000 fix: start.sh 在 chown 之前 COPY(确保 nextjs 用户可读) 2026-06-24 17:29:08 +08:00
胡旭 57bbf1fece fix: 去掉 chmod(sh 执行不需要 +x) 2026-06-24 17:24:42 +08:00
胡旭 38f2a9823b fix: sitemap 加 force-dynamic + try-catch(构建时不查数据库) 2026-06-24 17:20:01 +08:00
胡旭 89f8d6e223 fix: 所有数据页面加 force-dynamic(解决构建时预渲染空数据缓存)
构建时数据库为空,Next.js 缓存了空页面。
加 force-dynamic 强制每次请求时从数据库读取。
2026-06-24 17:14:26 +08:00
胡旭 92d190a081 fix: prisma db push 移到启动时执行(保留用户数据)
构建时执行会覆盖用户拷贝的数据库文件,
改为容器启动时执行,已有数据则跳过。
2026-06-24 16:48:07 +08:00
胡旭 e6839da566 fix: Docker 构建前 prisma db push 创建表 2026-06-24 16:38:46 +08:00
胡旭 faa17f0ccd fix: Docker 安装 openssl(Prisma 引擎需要 libssl.so.1.1) 2026-06-24 16:30:16 +08:00
胡旭 814729df02 fix: Docker runner 拷贝完整 node_modules(pnpm .prisma 在 .pnpm 内) 2026-06-24 16:22:37 +08:00
胡旭 7fbfaa9572 fix: Docker 改用 pnpm 9,绕过 pnpm 11 build scripts 限制
pnpm 11 的 onlyBuiltDependencies 配置在 Docker 中始终不生效,
改用 npm 全局安装 pnpm 9 彻底解决。
2026-06-24 16:10:16 +08:00
胡旭 3bebf669bd fix: pnpm 11 onlyBuiltDependencies 移到 .npmrc
pnpm 11 不再读取 package.json 中的 pnpm 字段,
需要在 .npmrc 中配置 onlyBuiltDependencies。
2026-06-24 16:01:17 +08:00
胡旭 5e77c0fa61 fix: Dockerfile 移除多余的 .npmrc 拷贝 2026-06-24 15:56:00 +08:00
胡旭 56cd507e81 fix: pnpm 11 build scripts 允许 Prisma/sharp 等包执行
pnpm 11 默认阻止 build scripts,导致 Prisma Client 生成失败。
在 package.json 中添加 pnpm.onlyBuiltDependencies 白名单。
2026-06-24 15:55:40 +08:00
胡旭 6de0f6ad3d fix: Docker 基础镜像改为 node:22-alpine(pnpm 11 需要 node:sqlite) 2026-06-24 15:45:05 +08:00
胡旭 9cc923e868 chore: 容器端口改为 8090 2026-06-24 15:40:31 +08:00
胡旭 2f1a7c906d chore: 添加 Docker 部署配置
- Dockerfile: 多阶段构建(deps → build → runner)
- docker-compose.yml: 一键部署,数据持久化到 volume
- next.config.ts: output standalone 模式
- .dockerignore: 排除 node_modules、.next、dev.db
2026-06-24 15:25:37 +08:00
胡旭 43e1c2f61d feat: 写作体验优化 + 封面图 + AI辅助写作 + 博客分页
写作体验优化:
- 自动保存草稿到 localStorage(debounce 2s,刷新不丢内容)
- 浏览器原生全屏专注写作模式
- Markdown 编辑模式(左右分栏,实时预览)
- 快捷键:Ctrl+S / Ctrl+Enter 保存,ESC 退出全屏

封面图功能:
- PostForm 新增封面图 URL 输入 + 实时预览
- BlogList 文章卡片显示封面缩略图
- PostContent 文章详情页显示封面大图

AI 辅助写作:
- OpenAI 兼容接口,SSE 流式返回
- 8 种预设操作:润色、扩写、精简、续写、纠错、翻译、摘要
- 自定义指令输入,结果可复制/替换/追加

其他:
- 后台文章列表改为一页10篇
- 前台 /blog 页面添加分页功能(一页10篇)
2026-06-24 15:22:03 +08:00
胡旭 18e915bcbb fix: 修复后台文章计数为0 + 分类编辑补全描述字段 + CSP放行外部图片 + 更新关于页描述
- 文章管理:计数请求补 page=1 参数,命中分页接口返回正确的 total
- 分类管理:编辑模式新增描述输入框,保存时一并提交 description
- CSP:img-src 加入 https: 允许加载外部图片
- 关于页:数据存储描述从 JSON 文件更正为 SQLite 数据库
- Footer:添加 ICP 备案号
2026-06-24 13:51:48 +08:00
胡旭 3707eddfd4 docs: 补全 README.md 项目文档 2026-06-24 08:49:09 +08:00
胡旭 dce8fe62ea feat: 重构博客为水墨纸质风格 + 搭建后台管理系统
- 重新设计全站 UI:parchment/ink/terracotta 水墨纸质色系,宋式 serif 排版
- 新增页面:文章列表、文章详情、分类、标签、关于
- GSAP ScrollTrigger 滚动动画 + 逐字揭示效果
- 后台管理系统 /admin:文章/分类/标签 CRUD,JSON 文件存储
- 登录认证(cookie session)
- 设计系统文档 UI.md
2026-06-24 08:47:14 +08:00
胡旭 507f12e501 Initial commit from Create Next App 2026-06-24 08:47:13 +08:00
86 changed files with 14008 additions and 0 deletions
+17
View File
@@ -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"
+48
View File
@@ -0,0 +1,48 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# prisma database
prisma/dev.db
prisma/dev.db-journal
prisma/dev.db-wal
prisma/dev.db-shm
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
onlyBuiltDependencies[]=prisma
onlyBuiltDependencies[]=@prisma/client
onlyBuiltDependencies[]=@prisma/engines
onlyBuiltDependencies[]=sharp
onlyBuiltDependencies[]=unrs-resolver
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+103
View File
@@ -1,2 +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
+183
View File
@@ -0,0 +1,183 @@
## Design System — sui_blog
水墨纸质杂志风格。暖色纸张底色、近黑墨色文字、赭石色点缀,整体营造宋式文人审美。
### 色彩
| Token | Hex | 用途 |
|---|---|---|
| `parchment` | `#FDFCFA` | 页面背景 |
| `parchment-deep` | `#F5F2EE` | 深底(代码块、时间轴圆点、标签底色) |
| `ink` | `#050404` | 主文字、标题 |
| `ink-light` | `#121010` | 略浅的墨色(about 正文) |
| `ink-muted` | `#2A2624` | 辅助文字、meta、标签 |
| `terracotta` | `#A63D2F` | 强调色(链接、CTA、active 状态) |
| `terracotta-light` | `#C46B5E` | 链接下划线装饰 |
| `sage` | `#6E8264` | 语义色——已发布/成功状态 |
| `sage-light` | `#A3B59B` | 预留 |
| `warm-gray` | `#C5BDB4` | 边框、分隔线、meta 点 |
| `cream` | `#FAF9F7` | 卡片背景、侧栏、深色按钮文字 |
辅助色:`red-600`(删除/错误)、`red-50`(删除 hover 背景)、`white`(内联编辑输入框)。
透明度层级:`/80``/50``/30``/20``/15``/10``/5`,用于边框和文字色微调。
选中文字:`bg-terracotta` + `text-cream`
### 字体
| Class | 字体栈 | 场景 |
|---|---|---|
| `font-display` | Noto Serif SC → Cormorant Garamond → Source Han Serif SC → Songti SC, serif | 标题、logo、页面名、时间轴年份、分类名 |
| `font-body` | Noto Serif SC → Source Serif 4 → Source Han Serif SC → Songti SC, serif | 正文段落、摘要、prose 内容 |
| `font-sans` | Noto Sans SC → DM Sans → system-ui, sans-serif | 标签、meta、导航、按钮、表单、badge |
| `font-mono` | JetBrains Mono → Fira Code, monospace | prose 中的代码、admin HTML textarea |
正文基础样式:`line-height: 1.9``letter-spacing: 0.02em`、开启 antialiased。
### 圆角
| Class | 用途 |
|---|---|
| `rounded-full` | 标签 pill、status badge、CTA 按钮、导航圆点、时间轴圆点 |
| `rounded-2xl` | 精选卡片、分类卡片、about 信息框 |
| `rounded-xl` | 标准卡片、表单输入框、admin 列表项、登录框 |
| `rounded-lg` | 侧栏导航项、操作按钮(保存/取消)、hover 背景 |
不使用 `rounded-sm``rounded-md``rounded-3xl`。层级关系:fullpill> 2xl(大卡片)> xl(标准卡片/输入框)> lg(小元素)。
### 间距
页面水平内边距:`px-page``clamp(1.5rem, 5vw, 6rem)`)。
内容宽度层级:`max-w-5xl`(页面)→ `max-w-3xl`(标签云)→ `max-w-2xl`(文章正文)→ `max-w-lg`(描述文字)→ `max-w-xs`(页脚/短输入)。
页面垂直间距:`pt-16 pb-24`(标准区块)、`pt-20 pb-24`hero 区)、`mt-20`(大段落分隔)。
常用 gap`gap-2`(标签 pill)、`gap-4`(网格卡片)、`gap-5`(精选/分类网格)。
卡片内边距:`p-7 md:p-10`(精选大卡片)、`p-7`(分类卡片)、`p-5`(列表项)、`p-4`(统计卡片)。
### 卡片
所有卡片共享基础样式:
```
bg-cream border border-warm-gray/10
```
Hover 提升:
```
hover:border-terracotta/20
```
大卡片额外加阴影:
```
hover:shadow-lg hover:shadow-terracotta/5
```
精选卡片 / 分类卡片完整样式:
```
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
```
不使用静态阴影。阴影仅出现在 hover 状态,且仅用于最大的两种卡片。
### 按钮
**主要 CTA(圆角胶囊):**
```
px-6 py-3 rounded-full bg-ink text-cream font-sans text-sm tracking-wide
hover:bg-terracotta transition-colors duration-300
```
**次要 CTA(描边胶囊):**
```
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
```
**Admin 主按钮(直角):**
```
px-4 py-2 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors
```
**Admin 次要按钮:**
```
px-6 py-2.5 rounded-lg border border-warm-gray/20 font-sans text-sm text-ink-muted
hover:text-ink transition-colors
```
**联系链接(描边胶囊):**
```
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
```
### 过渡与动画
**CSS 过渡(微交互):**
- `transition-colors duration-300` — 最常用,所有链接和按钮
- `transition-all duration-300` — 标签、联系按钮、列表项
- `transition-all duration-500` — 精选/分类大卡片(更缓的动效)
- `transition-opacity duration-300` — 箭头 hover 渐显
**自定义缓动:** `--ease-literary: cubic-bezier(0.25, 0.1, 0.25, 1)`
**CSS 关键帧动画:**
- `animate-fade-up` — translateY(20px) → 00.7s
- `animate-fade-in` — opacity 0 → 10.5s
**GSAP(主要动画引擎):**
- 缓动函数:`power3.out`(标准)、`power2.inOut`(分隔线)、`back.out(2)`(弹跳缩放)
- 逐字动画:拆分 `<span>` + stagger `y`/`opacity`/`blur`stagger 0.035s
- ScrollTrigger`start: "top 85%"` ~ `"top 92%"`
- GsapReveal 组件变体:`fade-up`(y:40)、`fade-in`(opacity:0)、`slide-left`(x:-40)、`slide-right`(x:40)、`scale`(0.92)
- Stagger 值:0.035(逐字)、0.04~0.15(卡片)、0.08~0.12(列表)
### 悬停效果
**文字:** `hover:text-terracotta`(主要)、`hover:text-ink`(次要)、`hover:text-red-600`(删除)
**背景:** `hover:bg-terracotta`(按钮)、`hover:bg-cream`(列表行高亮)、`hover:bg-warm-gray/10`(侧栏导航)
**边框:** `hover:border-terracotta/20`(卡片)、`hover:border-terracotta`(联系按钮)、`hover:border-terracotta/40`(次要 CTA
**Group hover** `group-hover:text-terracotta`(卡片标题)、`group-hover:opacity-100`(箭头/删除按钮渐显)、`group-hover:translate-x-0`(箭头滑入)
**表单聚焦:** `focus:border-terracotta/40 focus:outline-none`,复选框/单选框 `accent-terracotta`
### 布局
**根布局:** `min-h-full flex flex-col` + `<main className="flex-1">`sticky footer
**页面容器:** `px-page max-w-5xl mx-auto`
**Admin 布局:** `min-h-screen bg-parchment flex``aside w-56 shrink-0` + `flex-1 overflow-auto p-8 max-w-5xl`
**网格:**
- `grid md:grid-cols-2 gap-5` — 精选文章
- `grid sm:grid-cols-2 lg:grid-cols-3 gap-5` — 分类
- `grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4` — Admin 统计
- `grid grid-cols-2 gap-4` — Admin 表单双列行
### Prose 排版
文章正文使用 `.prose-literary` 类:字号 1.0625rem、行高 2、字距 0.04em。段落首行缩进 2em(首段不缩进),段间距 1.5em。h2 使用 `font-display` 1.75remh3 为 1.35rem。引用块左侧 3px terracotta 色竖线。代码使用 `font-mono` + `bg-parchment-deep`。链接使用 terracotta 色 + 下划线,hover 时下划线加深。
### 全局细节
- 滚动条:6px 宽,track 为 `parchment-deep`thumb 为 `warm-gray`hover 变 `ink-muted`
- 纸质纹理:`body::before` 叠加 SVG fractalNoiseopacity 0.03
- 分隔线装饰:`.divider-ornament` 使用两端渐变线 + 中间符号
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+37
View File
@@ -0,0 +1,37 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
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;
+53
View File
@@ -0,0 +1,53 @@
{
"name": "sui_blog",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@prisma/client": "5",
"@tiptap/extension-code-block-lowlight": "^3.27.1",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-link": "^3.27.1",
"@tiptap/extension-placeholder": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"gsap": "^3.15.0",
"lowlight": "^3.3.0",
"lucide-react": "^1.21.0",
"marked": "^18.0.5",
"next": "16.2.9",
"next-themes": "^0.4.6",
"prisma": "5",
"react": "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": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+6840
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+36
View File
@@ -0,0 +1,36 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Post {
id String @id
slug String @unique
title String
excerpt String @default("")
content String
date String
category String
tags String // JSON 序列化的 string[]
coverImage String?
readingTime Int @default(5)
featured Boolean @default(false)
status String @default("draft")
createdAt String
updatedAt String
}
model Category {
id String @id
name String @unique
description String @default("")
}
model Tag {
id String @id
name String @unique
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+221
View File
@@ -0,0 +1,221 @@
import Link from "next/link";
import GsapReveal from "@/components/GsapReveal";
export const metadata = {
title: "关于",
description: "关于我和这个博客",
};
const timeline = [
{
year: "2026",
title: "AI 图像生成",
desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境",
},
{
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() {
return (
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
<div className="max-w-2xl">
{/* Header */}
<GsapReveal variant="fade-up" className="mb-14">
<h1 className="font-display text-4xl md:text-5xl font-light text-ink tracking-tight">
</h1>
<p className="mt-3 font-sans text-sm text-ink-muted tracking-wide">
About me & this blog
</p>
</GsapReveal>
{/* Intro */}
<GsapReveal
variant="fade-up"
stagger={0.1}
className="space-y-6 font-body text-base text-ink-light leading-relaxed"
>
<p>
<span className="text-ink font-medium">Sui</span>
00
</p>
<p>
AI Web 3D
3D
</p>
<p>
</p>
<p className="text-ink-muted">
</p>
</GsapReveal>
{/* Contact */}
<GsapReveal
variant="fade-up"
stagger={0.08}
className="mt-12 flex flex-wrap gap-3"
>
<a
href="http://gitea.asui.xyz/huxu"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-warm-gray/20 font-sans text-sm text-ink-muted hover:border-terracotta hover:text-terracotta transition-all duration-300"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Gitea
</a>
<a
href="mailto:arieshuxu@163.com"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-warm-gray/20 font-sans text-sm text-ink-muted hover:border-terracotta hover:text-terracotta transition-all duration-300"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<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>
E-mail
</a>
<a
href="https://www.asui.xyz"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-warm-gray/20 font-sans text-sm text-ink-muted hover:border-terracotta hover:text-terracotta transition-all duration-300"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9" />
</svg>
asui.xyz
</a>
</GsapReveal>
{/* Timeline */}
<div className="mt-20">
<GsapReveal variant="fade-up" className="mb-8">
<h2 className="font-display text-2xl font-medium text-ink">
线
</h2>
</GsapReveal>
<GsapReveal variant="slide-left" stagger={0.12} className="space-y-0">
{timeline.map((item, i) => (
<div key={item.year} className="relative pl-12 pb-10">
{i < timeline.length - 1 && (
<div className="absolute left-[11px] top-5 bottom-0 w-px bg-warm-gray/20" />
)}
<div className="absolute left-0 top-1.5 w-[22px] h-[22px] rounded-full border-2 border-warm-gray/20 bg-parchment flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-terracotta" />
</div>
<div>
<span className="font-sans text-sm text-terracotta tracking-wide">
{item.year}
</span>
<h3 className="font-display text-lg font-medium text-ink mt-1">
{item.title}
</h3>
<p className="font-body text-sm text-ink-muted mt-1 leading-relaxed">
{item.desc}
</p>
</div>
</div>
))}
</GsapReveal>
</div>
{/* Tech stack */}
<div className="mt-16">
<GsapReveal variant="fade-up" className="mb-6">
<h2 className="font-display text-2xl font-medium text-ink">
</h2>
</GsapReveal>
<GsapReveal
variant="scale"
stagger={0.04}
className="flex flex-wrap gap-2"
>
{[
"React",
"TypeScript",
"Next.js",
"Tailwind CSS",
"Three.js",
"React Three Fiber",
"Python",
"Stable Diffusion",
"Node.js",
"Vite",
"GSAP",
"Docker",
].map((tech) => (
<span
key={tech}
className="px-3.5 py-1.5 rounded-full bg-cream border border-warm-gray/10 font-sans text-sm text-ink-muted"
>
{tech}
</span>
))}
</GsapReveal>
</div>
{/* Colophon */}
<GsapReveal variant="fade-up" className="mt-20">
<div className="p-8 rounded-2xl bg-cream border border-warm-gray/10">
<h3 className="font-display text-lg font-medium text-ink mb-3">
</h3>
<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>
SQLite 使 Noto Serif
SC Cormorant Garamond
</p>
</div>
</GsapReveal>
</div>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect } from "react";
import type { Category } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/admin/ConfirmDialog";
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const { toast } = useToast();
async function load() {
try {
const res = await safeFetch("/api/categories", undefined, toast);
setCategories(await res.json());
} catch { /* safeFetch 已弹 toast */ }
setLoading(false);
}
useEffect(() => { load(); }, [toast]);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
await safeFetch("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
}, toast);
toast(`已添加分类「${newName.trim()}`, "success");
setNewName("");
setNewDesc("");
load();
} catch { /* safeFetch 已弹 toast */ }
}
async function confirmDelete() {
if (!deleteTarget) return;
try {
await safeFetch(`/api/categories?id=${deleteTarget.id}`, { method: "DELETE" }, toast);
toast(`已删除分类「${deleteTarget.name}`, "success");
load();
} catch { /* safeFetch 已弹 toast */ }
setDeleteTarget(null);
}
async function handleSave(id: string) {
try {
await safeFetch(`/api/categories?id=${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName, description: editDesc }),
}, toast);
toast("分类已更新", "success");
setEditingId(null);
load();
} catch { /* safeFetch 已弹 toast */ }
}
if (loading) return <div className="font-sans text-muted-foreground">...</div>;
return (
<div>
<h1 className="font-display text-3xl font-medium mb-8"></h1>
{/* Add form */}
<form onSubmit={handleAdd} className="flex flex-col sm:flex-row gap-3 mb-8">
<div className="flex-1 max-w-xs space-y-1">
<Label htmlFor="cat-name" className="sr-only"></Label>
<Input
id="cat-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="分类名称"
/>
</div>
<div className="flex-1 max-w-sm space-y-1">
<Label htmlFor="cat-desc" className="sr-only"></Label>
<Input
id="cat-desc"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="描述(可选)"
/>
</div>
<Button type="submit" className="shrink-0"></Button>
</form>
{/* List */}
<div className="space-y-2">
{categories.map((cat) => (
<div key={cat.id} className="flex items-center justify-between p-4 rounded-xl bg-card border border-border">
{editingId === cat.id ? (
<div className="flex items-center gap-2 flex-1">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-8 max-w-xs"
autoFocus
/>
<Input
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
className="h-8 max-w-sm"
placeholder="描述(可选)"
/>
<Button size="sm" variant="ghost" onClick={() => handleSave(cat.id)} className="text-accent"></Button>
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}></Button>
</div>
) : (
<>
<div>
<span className="font-display text-base">{cat.name}</span>
{cat.description && <span className="ml-3 font-sans text-sm text-muted-foreground">{cat.description}</span>}
</div>
<div className="flex items-center gap-3">
<button
onClick={() => { setEditingId(cat.id); setEditName(cat.name); setEditDesc(cat.description); }}
className="font-sans text-xs text-muted-foreground hover:text-primary transition-colors"
></button>
<button onClick={() => setDeleteTarget({ id: cat.id, name: cat.name })} className="font-sans text-xs text-muted-foreground hover:text-red-600 transition-colors"></button>
</div>
</>
)}
</div>
))}
{categories.length === 0 && (
<div className="text-center py-16 font-sans text-muted-foreground"></div>
)}
</div>
<ConfirmDialog
open={!!deleteTarget}
title="删除分类"
description={`确定删除分类「${deleteTarget?.name}」?此操作不可撤销。`}
confirmText="删除"
variant="destructive"
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { ToastProvider } from "@/components/Toast";
const navItems = [
{ label: "仪表盘", href: "/admin", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4" },
{ label: "文章", href: "/admin/posts", icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2" },
{ label: "分类", href: "/admin/categories", icon: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" },
{ label: "标签", href: "/admin/tags", icon: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [authed, setAuthed] = useState<boolean | null>(null);
const isLoginPage = pathname === "/admin/login";
useEffect(() => {
if (isLoginPage) return;
fetch("/api/auth")
.then((r) => r.json())
.then((data) => {
if (!data.authenticated) router.push("/admin/login");
else setAuthed(true);
})
.catch(() => router.push("/admin/login"));
}, [router, isLoginPage]);
// Login page — 渲染 children 不加侧栏
if (isLoginPage) {
return <ToastProvider>{children}</ToastProvider>;
}
if (authed === null || !authed) {
return (
<div className="min-h-screen bg-background flex items-center justify-center font-sans text-muted-foreground" role="status">
...
</div>
);
}
async function handleLogout() {
await fetch("/api/auth", { method: "DELETE" });
router.push("/admin/login");
}
return (
<ToastProvider>
<div className="min-h-screen bg-background flex">
{/* Sidebar — desktop */}
<aside className="w-56 shrink-0 border-r border-border bg-card/50 hidden md:flex flex-col">
<div className="p-5 border-b border-border">
<Link href="/admin" className="font-display text-lg font-medium hover:text-primary transition-colors">
· Admin
</Link>
</div>
<nav className="flex-1 p-3 space-y-1" aria-label="管理后台导航">
{navItems.map((item) => {
const isActive = item.href === "/admin"
? pathname === "/admin"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm transition-colors ${
isActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/30"
}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d={item.icon} />
</svg>
{item.label}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-border space-y-1">
<Link href="/" target="_blank" className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-muted-foreground hover:text-red-600 hover:bg-red-50 transition-colors text-left"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
退
</button>
</div>
</aside>
{/* Mobile top bar */}
<div className="md:hidden fixed top-0 inset-x-0 z-50 bg-background/90 backdrop-blur-sm border-b border-border flex items-center justify-between px-4 h-12">
<Link href="/admin" className="font-display text-base font-medium">Admin</Link>
<nav className="flex items-center gap-2" aria-label="管理后台导航">
{navItems.map((item) => {
const isActive = item.href === "/admin"
? pathname === "/admin"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
aria-current={isActive ? "page" : undefined}
className={`p-1.5 rounded-lg transition-colors ${isActive ? "text-primary" : "text-muted-foreground"}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d={item.icon} />
</svg>
</Link>
);
})}
<button
onClick={handleLogout}
aria-label="退出登录"
className="p-1.5 rounded-lg text-muted-foreground hover:text-red-600 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</nav>
</div>
{/* Main */}
<div className="flex-1 overflow-auto md:pt-0 pt-12">
<div className="p-6 md:p-8">
{children}
</div>
</div>
</div>
</ToastProvider>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useToast, safeFetch } from "@/components/Toast";
import { ToastProvider } from "@/components/Toast";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
export default function LoginPage() {
return (
<ToastProvider>
<LoginForm />
</ToastProvider>
);
}
function LoginForm() {
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await safeFetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
}, toast);
router.push("/admin");
} catch { /* safeFetch 已弹 toast */ }
setLoading(false);
}
return (
<div className="min-h-screen bg-background flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-10">
<h1 className="font-display text-3xl font-medium"></h1>
<p className="mt-2 font-sans text-sm text-muted-foreground">asui.xyz</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="输入管理密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? "验证中..." : "登录"}
</Button>
</form>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import type { Post } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
interface Stats {
total: number;
published: number;
draft: number;
featured: number;
categories: number;
tags: number;
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [recentPosts, setRecentPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
Promise.all([
safeFetch("/api/stats", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?page=1&pageSize=5&sortBy=createdAt&sortDir=desc", undefined, toast).then((r) => r.json()),
]).then(([s, postsResult]) => {
setStats(s);
setRecentPosts(postsResult.data ?? postsResult);
setLoading(false);
}).catch(() => setLoading(false));
}, [toast]);
if (loading) return <div className="font-sans text-muted-foreground">...</div>;
const statItems = stats ? [
{ label: "文章总数", value: stats.total, color: "text-foreground" },
{ label: "已发布", value: stats.published, color: "text-accent" },
{ label: "草稿", value: stats.draft, color: "text-primary" },
{ label: "精选", value: stats.featured, color: "text-primary" },
{ label: "分类", value: stats.categories, color: "text-foreground" },
{ label: "标签", value: stats.tags, color: "text-foreground" },
] : [];
return (
<div>
<div className="flex items-center justify-between mb-8">
<h1 className="font-display text-3xl font-medium"></h1>
<Link href="/admin/posts/new" className="inline-flex items-center px-4 py-2 rounded-lg bg-primary text-primary-foreground font-sans text-sm hover:bg-primary/90 transition-colors">
+
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-10">
{statItems.map((s) => (
<div key={s.label} className="p-4 rounded-xl bg-card border border-border">
<div className={`font-display text-2xl font-medium ${s.color}`}>{s.value}</div>
<div className="font-sans text-xs text-muted-foreground mt-1">{s.label}</div>
</div>
))}
</div>
{/* Recent posts */}
<h2 className="font-display text-xl font-medium mb-4"></h2>
<div className="space-y-2">
{recentPosts.map((post) => (
<Link
key={post.id}
href={`/admin/posts/${post.id}`}
className="flex items-center justify-between p-4 rounded-xl bg-card border border-border hover:border-primary/20 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="font-display text-base truncate">{post.title}</div>
<div className="font-sans text-xs text-muted-foreground mt-0.5">{post.category} · {post.date}</div>
</div>
<span className={`ml-4 shrink-0 font-sans text-xs px-2 py-0.5 rounded-full ${
post.status === "published" ? "bg-accent/10 text-accent" : "bg-primary/10 text-primary"
}`}>
{post.status === "published" ? "已发布" : "草稿"}
</span>
</Link>
))}
{recentPosts.length === 0 && (
<div className="text-center py-8 font-sans text-muted-foreground"></div>
)}
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import type { Post, Category, Tag } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import PostForm from "@/components/admin/PostForm";
import type { PostFormData } from "@/components/admin/PostForm";
export default function EditPostPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const { toast } = useToast();
const [post, setPost] = useState<Post | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
safeFetch(`/api/posts/${id}`, undefined, toast).then((r) => r.json()),
safeFetch("/api/categories", undefined, toast).then((r) => r.json()),
safeFetch("/api/tags", undefined, toast).then((r) => r.json()),
]).then(([p, cats, tgs]) => {
setPost(p);
setCategories(cats);
setAllTags(tgs);
}).catch(() => {})
.finally(() => setLoading(false));
}, [id, toast]);
async function handleSubmit(data: PostFormData) {
await safeFetch(`/api/posts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}, toast);
toast("文章已更新", "success");
router.push("/admin/posts");
}
if (loading) return <div className="font-sans text-muted-foreground">...</div>;
if (!post) return <div className="font-sans text-red-600"></div>;
return (
<div>
<div className="flex items-center gap-4 mb-8">
<button onClick={() => router.back()} className="font-sans text-sm text-muted-foreground hover:text-foreground transition-colors">
</button>
<h1 className="font-display text-3xl font-medium"></h1>
</div>
<PostForm
mode="edit"
initialData={post}
categories={categories}
tags={allTags}
onSubmit={handleSubmit}
onCancel={() => router.back()}
/>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Category, Tag } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import PostForm from "@/components/admin/PostForm";
import type { PostFormData } from "@/components/admin/PostForm";
export default function NewPostPage() {
const router = useRouter();
const { toast } = useToast();
const [categories, setCategories] = useState<Category[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
useEffect(() => {
Promise.all([
safeFetch("/api/categories", undefined, toast).then((r) => r.json()),
safeFetch("/api/tags", undefined, toast).then((r) => r.json()),
]).then(([cats, tgs]) => {
setCategories(cats);
setAllTags(tgs);
}).catch(() => {});
}, [toast]);
async function handleSubmit(data: PostFormData) {
await safeFetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}, toast);
toast("文章创建成功", "success");
router.push("/admin/posts");
}
return (
<div>
<div className="flex items-center gap-4 mb-8">
<button onClick={() => router.back()} className="font-sans text-sm text-muted-foreground hover:text-foreground transition-colors">
</button>
<h1 className="font-display text-3xl font-medium"></h1>
</div>
<PostForm
mode="create"
categories={categories}
tags={allTags}
onSubmit={handleSubmit}
onCancel={() => router.back()}
/>
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import type { Post } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, ChevronLeft, ChevronRight } from "lucide-react";
export default function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [sortKey, setSortKey] = useState<"date" | "createdAt" | "title" | "readingTime">("date");
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [counts, setCounts] = useState({ all: 0, published: 0, draft: 0 });
const pageSize = 10;
const { toast } = useToast();
// 搜索 debounce300ms 后才更新 debouncedSearch
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
// 筛选/排序变化时重置到第 1 页
useEffect(() => { setPage(1); }, [filter, sortKey, sortDir]);
// 加载文章列表
const loadPosts = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
sortBy: sortKey,
sortDir,
});
if (filter !== "all") params.set("status", filter);
if (debouncedSearch.trim()) params.set("search", debouncedSearch.trim());
const res = await safeFetch(`/api/posts?${params}`, undefined, toast);
const result = await res.json();
if (result.data) {
setPosts(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
} else {
setPosts(result);
setTotal(result.length);
setTotalPages(1);
}
} catch { /* safeFetch 已弹 toast */ }
setLoading(false);
}, [page, pageSize, sortKey, sortDir, filter, debouncedSearch, toast]);
useEffect(() => { loadPosts(); }, [loadPosts]);
// 加载各状态计数(独立请求,不受筛选影响)
useEffect(() => {
Promise.all([
safeFetch("/api/posts?page=1&pageSize=1", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?pageSize=1&status=published", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?pageSize=1&status=draft", undefined, toast).then((r) => r.json()),
]).then(([all, pub, draft]) => {
setCounts({
all: all.total ?? 0,
published: pub.total ?? 0,
draft: draft.total ?? 0,
});
}).catch(() => {});
}, [toast]); // 只在 mount 时加载一次
async function handleDelete(id: string, title: string) {
if (!confirm(`确定删除「${title}」?`)) return;
try {
await safeFetch(`/api/posts?id=${id}`, { method: "DELETE" }, toast);
toast(`已删除「${title}`, "success");
loadPosts();
// 重新加载计数
const [all, pub, draft] = await Promise.all([
safeFetch("/api/posts?page=1&pageSize=1", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?pageSize=1&status=published", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?pageSize=1&status=draft", undefined, toast).then((r) => r.json()),
]);
setCounts({ all: all.total ?? 0, published: pub.total ?? 0, draft: draft.total ?? 0 });
} catch { /* safeFetch 已弹 toast */ }
}
if (loading && posts.length === 0) return <div className="font-sans text-muted-foreground">...</div>;
return (
<div>
<div className="flex items-center justify-between mb-8">
<h1 className="font-display text-3xl font-medium"></h1>
<Link href="/admin/posts/new" className="inline-flex items-center px-4 py-2 rounded-lg bg-primary text-primary-foreground font-sans text-sm hover:bg-primary/90 transition-colors">
+
</Link>
</div>
{/* 搜索 + 筛选 + 排序 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<div className="flex gap-4 font-sans text-sm">
{(["all", "published", "draft"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`pb-1 border-b-2 transition-colors ${
filter === f ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{f === "all" ? "全部" : f === "published" ? "已发布" : "草稿"}
<span className="ml-1 text-xs text-muted-foreground">({counts[f]})</span>
</button>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:w-56">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索标题、摘要、分类..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-9"
/>
</div>
<select
value={`${sortKey}-${sortDir}`}
onChange={(e) => {
const [k, d] = e.target.value.split("-") as [typeof sortKey, typeof sortDir];
setSortKey(k);
setSortDir(d);
}}
className="h-9 px-2 rounded-md border border-border bg-card text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="date-desc"> </option>
<option value="date-asc"> </option>
<option value="createdAt-desc"> </option>
<option value="createdAt-asc"> </option>
<option value="title-asc"> AZ</option>
<option value="title-desc"> ZA</option>
<option value="readingTime-desc"> </option>
<option value="readingTime-asc"> </option>
</select>
</div>
</div>
{/* 文章列表 */}
<div className="space-y-3">
{posts.map((post) => (
<div key={post.id} className="p-5 rounded-xl bg-card border border-border hover:border-primary/20 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<Link href={`/admin/posts/${post.id}`} className="font-display text-lg text-foreground hover:text-primary transition-colors">
{post.title}
</Link>
<p className="font-sans text-sm text-muted-foreground mt-1 line-clamp-1">{post.excerpt}</p>
<div className="flex items-center gap-3 mt-2 font-sans text-xs text-muted-foreground">
<span>{post.category}</span>
<span>·</span>
<span>{post.date}</span>
<span>·</span>
<span>{post.readingTime} </span>
{post.featured && <span className="text-primary"></span>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`font-sans text-xs px-2 py-0.5 rounded-full ${
post.status === "published" ? "bg-accent/10 text-accent" : "bg-primary/10 text-primary"
}`}>
{post.status === "published" ? "已发布" : "草稿"}
</span>
<Link href={`/admin/posts/${post.id}`} className="font-sans text-xs text-muted-foreground hover:text-primary transition-colors px-2">
</Link>
<button onClick={() => handleDelete(post.id, post.title)} className="font-sans text-xs text-muted-foreground hover:text-red-600 transition-colors px-2">
</button>
</div>
</div>
</div>
))}
{posts.length === 0 && !loading && (
<div className="text-center py-16 font-sans text-muted-foreground">
{debouncedSearch ? "未找到匹配的文章" : "暂无文章"}
</div>
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-8 font-sans text-sm text-muted-foreground">
<span> {total} {page}/{totalPages} </span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<Button
key={pageNum}
variant={pageNum === page ? "default" : "outline"}
size="sm"
onClick={() => setPage(pageNum)}
className="w-8"
>
{pageNum}
</Button>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useState, useEffect } from "react";
import type { Tag } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/admin/ConfirmDialog";
export default function TagsPage() {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const { toast } = useToast();
async function load() {
try {
const res = await safeFetch("/api/tags", undefined, toast);
setTags(await res.json());
} catch { /* safeFetch 已弹 toast */ }
setLoading(false);
}
useEffect(() => { load(); }, [toast]);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
await safeFetch("/api/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
}, toast);
toast(`已添加标签「${newName.trim()}`, "success");
setNewName("");
load();
} catch { /* safeFetch 已弹 toast */ }
}
async function confirmDelete() {
if (!deleteTarget) return;
try {
await safeFetch(`/api/tags?id=${deleteTarget.id}`, { method: "DELETE" }, toast);
toast(`已删除标签「${deleteTarget.name}`, "success");
load();
} catch { /* safeFetch 已弹 toast */ }
setDeleteTarget(null);
}
if (loading) return <div className="font-sans text-muted-foreground">...</div>;
return (
<div>
<h1 className="font-display text-3xl font-medium mb-8"></h1>
{/* Add form */}
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
<div className="flex-1 max-w-xs space-y-1">
<Label htmlFor="tag-name" className="sr-only"></Label>
<Input
id="tag-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="标签名称"
/>
</div>
<Button type="submit" className="shrink-0"></Button>
</form>
{/* Tag cloud */}
<div className="p-6 rounded-xl bg-card border border-border">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span key={tag.id} className="group inline-flex items-center gap-1.5 font-sans text-sm px-3 py-1.5 rounded-full border border-border text-foreground hover:border-primary/30 transition-colors">
{tag.name}
<button
onClick={() => setDeleteTarget({ id: tag.id, name: tag.name })}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-600 transition-all text-xs leading-none"
aria-label={`删除标签 ${tag.name}`}
>×</button>
</span>
))}
</div>
{tags.length === 0 && (
<div className="text-center py-8 font-sans text-muted-foreground"></div>
)}
</div>
<ConfirmDialog
open={!!deleteTarget}
title="删除标签"
description={`确定删除标签「${deleteTarget?.name}」?此操作不可撤销。`}
confirmText="删除"
variant="destructive"
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}
+94
View File
@@ -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" } }
);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { checkAuth, createSession, SESSION_KEY } from "@/lib/auth";
import { registerFailedAttempt, clearAttempts, isLocked } from "@/lib/rate-limit";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "asui2026";
const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 天
/** 取客户端 IP 作为限流 key,兼容代理转发头。 */
function clientKey(request: NextRequest): string {
const fwd = request.headers.get("x-forwarded-for");
const ip = fwd ? fwd.split(",")[0].trim() : request.headers.get("x-real-ip") || "unknown";
return `login:${ip}`;
}
export async function POST(request: NextRequest) {
const key = clientKey(request);
const lock = isLocked(key);
if (lock.locked) {
return NextResponse.json(
{ error: `尝试次数过多,请 ${Math.ceil(lock.retryAfterSec / 60)} 分钟后再试` },
{ status: 429 }
);
}
let body: { password?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "请求格式错误" }, { status: 400 });
}
if (typeof body.password !== "string" || body.password !== ADMIN_PASSWORD) {
const result = registerFailedAttempt(key);
if (result.locked) {
return NextResponse.json(
{ error: "密码错误次数过多,账户已锁定 15 分钟" },
{ status: 429 }
);
}
return NextResponse.json({ error: "密码错误" }, { status: 401 });
}
clearAttempts(key);
const token = await createSession(SESSION_MAX_AGE);
const cookieStore = await cookies();
cookieStore.set(SESSION_KEY, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SESSION_MAX_AGE,
path: "/",
});
return NextResponse.json({ ok: true });
}
export async function DELETE() {
const cookieStore = await cookies();
cookieStore.delete(SESSION_KEY);
return NextResponse.json({ ok: true });
}
export async function GET() {
return NextResponse.json({ authenticated: await checkAuth() });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { getCategories, createCategory, updateCategory, deleteCategory } from "@/lib/store";
import { requireAuth, parseBody } from "@/lib/http";
import { categorySchema } from "@/lib/validation";
export async function GET() {
const deny = await requireAuth();
if (deny) return deny;
return NextResponse.json(await getCategories());
}
export async function POST(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
const parsed = await parseBody(request, categorySchema);
if (!parsed.ok) return parsed.response;
return NextResponse.json(await createCategory(parsed.data), { status: 201 });
}
export async function PUT(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const parsed = await parseBody(request, categorySchema);
if (!parsed.ok) return parsed.response;
const cat = await updateCategory(id, parsed.data);
if (!cat) return NextResponse.json({ error: "未找到" }, { status: 404 });
return NextResponse.json(cat);
}
export async function DELETE(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
return NextResponse.json({ ok: await deleteCategory(id) });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getPost, updatePost } from "@/lib/store";
import { requireAuth, parseBody } from "@/lib/http";
import { updatePostSchema } from "@/lib/validation";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const deny = await requireAuth();
if (deny) return deny;
const { id } = await params;
const post = await getPost(id);
if (!post) return NextResponse.json({ error: "未找到" }, { status: 404 });
return NextResponse.json(post);
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const deny = await requireAuth();
if (deny) return deny;
const { id } = await params;
const parsed = await parseBody(request, updatePostSchema);
if (!parsed.ok) return parsed.response;
const post = await updatePost(id, parsed.data);
if (!post) return NextResponse.json({ error: "未找到" }, { status: 404 });
return NextResponse.json(post);
}
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { getPosts, getPostsPaginated, createPost, deletePost } from "@/lib/store";
import { requireAuth, parseBody } from "@/lib/http";
import { createPostSchema } from "@/lib/validation";
export async function GET(request: NextRequest) {
const deny = await requireAuth();
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);
}
// 兼容旧接口:无参数时返回全量
return NextResponse.json(await getPosts());
}
export async function POST(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
const parsed = await parseBody(request, createPostSchema);
if (!parsed.ok) return parsed.response;
const post = await createPost(parsed.data);
return NextResponse.json(post, { status: 201 });
}
export async function DELETE(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
return NextResponse.json({ ok: await deletePost(id) });
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getStats } from "@/lib/store";
import { requireAuth } from "@/lib/http";
export async function GET() {
const deny = await requireAuth();
if (deny) return deny;
const stats = await getStats();
return NextResponse.json(stats);
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { getTags, createTag, deleteTag } from "@/lib/store";
import { requireAuth, parseBody } from "@/lib/http";
import { tagSchema } from "@/lib/validation";
export async function GET() {
const deny = await requireAuth();
if (deny) return deny;
return NextResponse.json(await getTags());
}
export async function POST(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
const parsed = await parseBody(request, tagSchema);
if (!parsed.ok) return parsed.response;
return NextResponse.json(await createTag(parsed.data), { status: 201 });
}
export async function DELETE(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
return NextResponse.json({ ok: await deleteTag(id) });
}
+19
View File
@@ -0,0 +1,19 @@
import { Suspense } from "react";
import { getPublishedPosts } from "@/lib/store";
import BlogList from "@/components/BlogList";
export const dynamic = "force-dynamic";
export const metadata = {
title: "文章",
description: "胡旭的博客文章 — 技术、随笔、旅行、阅读",
};
export default async function BlogPage() {
const posts = await getPublishedPosts();
return (
<Suspense fallback={null}>
<BlogList posts={posts} />
</Suspense>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import { getPublicCategories, getPostsByCategory } from "@/lib/store";
import GsapReveal from "@/components/GsapReveal";
export const dynamic = "force-dynamic";
export const metadata = {
title: "分类",
description: "按分类浏览博客文章",
};
const categoryIcons: Record<string, string> = {
: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
: "M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z",
: "M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064",
: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253",
: "M13 10V3L4 14h7v7l9-11h-7z",
};
export default async function CategoriesPage() {
const categories = await getPublicCategories();
// 预取每个分类的文章,避免在 JSX 中使用 await
const categoriesWithPosts = await Promise.all(
categories.map(async (cat) => ({
...cat,
posts: (await getPostsByCategory(cat.name)).slice(0, 3),
}))
);
return (
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
<GsapReveal variant="fade-up" className="mb-14">
<h1 className="font-display text-4xl md:text-5xl font-light text-ink tracking-tight">
</h1>
<p className="mt-3 font-body text-ink-muted max-w-md">
</p>
</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">
{categoriesWithPosts.map((cat) => {
const icon = categoryIcons[cat.name] || categoryIcons["随笔"];
return (
<div
key={cat.id}
className="group relative p-7 rounded-2xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 hover:shadow-lg hover:shadow-terracotta/5 transition-all duration-500"
>
<div className="w-10 h-10 rounded-xl bg-parchment-deep flex items-center justify-center mb-5 group-hover:bg-terracotta/10 transition-colors duration-300">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-ink-muted group-hover:text-terracotta transition-colors duration-300">
<path d={icon} />
</svg>
</div>
<div className="flex items-baseline gap-2 mb-2">
<Link
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>
</div>
<p className="font-body text-sm text-ink-muted leading-relaxed mb-5">
{cat.description}
</p>
<div className="space-y-2">
{cat.posts.map((post) => (
<Link
key={post.slug}
href={`/posts/${post.slug}`}
className="block font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-200 truncate"
>
{post.title}
</Link>
))}
</div>
</div>
);
})}
</GsapReveal>
) : (
<div className="py-24 text-center">
<p className="font-display text-2xl text-ink-muted mb-2"></p>
</div>
)}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+302
View File
@@ -0,0 +1,302 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
/* ── Design Tokens ── */
@theme inline {
/* Parchment & earth palette — ink-wash tones */
--color-parchment: #FDFCFA;
--color-parchment-deep: #F5F2EE;
--color-ink: #050404;
--color-ink-light: #121010;
--color-ink-muted: #2A2624;
--color-terracotta: #A63D2F;
--color-terracotta-light: #C46B5E;
--color-sage: #6E8264;
--color-sage-light: #A3B59B;
--color-warm-gray: #C5BDB4;
--color-cream: #FAF9F7;
/* Typography — 宋式 serif priority。
next/font 注入的 CSS 变量优先,回退到本地系统宋体。 */
--font-display: var(--font-noto-serif), "Cormorant Garamond", "Source Han Serif SC", "Songti SC", serif;
--font-body: var(--font-noto-serif), "Source Han Serif SC", "Songti SC", serif;
--font-sans: var(--font-sans);
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
/* Spacing scale */
--spacing-page: clamp(1.5rem, 5vw, 6rem);
/* Transitions */
--ease-literary: cubic-bezier(0.25, 0.1, 0.25, 1);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* ── Base Styles ── */
html {
scroll-behavior: smooth;
}
body {
background-color: var(--color-parchment);
color: var(--color-ink);
font-family: var(--font-body);
line-height: 1.9;
letter-spacing: 0.02em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Paper grain texture ── */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
/* ── Selection color ── */
::selection {
background-color: var(--color-terracotta);
color: var(--color-cream);
}
/* ── Custom scrollbar ── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-parchment-deep);
}
::-webkit-scrollbar-thumb {
background: var(--color-warm-gray);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-ink-muted);
}
/* ── Typography utilities ── */
.font-display {
font-family: var(--font-display);
}
.font-body {
font-family: var(--font-body);
}
/* ── Prose styling for article content ── */
.prose-literary {
font-family: var(--font-body);
font-size: 1.0625rem;
line-height: 2;
letter-spacing: 0.04em;
color: var(--color-ink);
}
.prose-literary p {
margin-bottom: 1.5em;
text-indent: 2em;
}
.prose-literary p:first-child {
text-indent: 0;
}
.prose-literary h2 {
font-family: var(--font-display);
font-size: 1.75rem;
font-weight: 500;
color: var(--color-ink);
margin-top: 2.5em;
margin-bottom: 0.75em;
letter-spacing: 0.02em;
}
.prose-literary h3 {
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 500;
color: var(--color-ink);
margin-top: 2em;
margin-bottom: 0.5em;
}
.prose-literary blockquote {
border-left: 3px solid var(--color-terracotta);
padding-left: 1.5em;
margin: 2em 0;
color: var(--color-ink-muted);
font-style: italic;
}
.prose-literary code {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--color-parchment-deep);
padding: 0.15em 0.4em;
border-radius: 4px;
}
.prose-literary a {
color: var(--color-terracotta);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--color-terracotta-light);
transition: text-decoration-color 0.3s var(--ease-literary);
}
.prose-literary a:hover {
text-decoration-color: var(--color-terracotta);
}
.prose-literary img {
border-radius: 8px;
margin: 2em 0;
}
/* ── Fade-in animation ── */
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-up {
animation: fade-up 0.7s var(--ease-literary) both;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in {
animation: fade-in 0.5s var(--ease-literary) both;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ── Ink brush underline decoration ── */
.ink-underline {
position: relative;
display: inline-block;
}
.ink-underline::after {
content: "";
position: absolute;
left: 0;
bottom: -4px;
width: 100%;
height: 3px;
background: var(--color-terracotta);
border-radius: 2px;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.4s var(--ease-literary);
}
.ink-underline:hover::after {
transform: scaleX(1);
}
/* ── Divider ornament ── */
.divider-ornament {
display: flex;
align-items: center;
gap: 1rem;
color: var(--color-warm-gray);
}
.divider-ornament::before,
.divider-ornament::after {
content: "";
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, var(--color-warm-gray), transparent);
}
:root {
/* 水墨纸质风格 — 映射到 shadcn CSS 变量 */
--background: #FDFCFA; /* parchment */
--foreground: #050404; /* ink */
--card: #FAF9F7; /* cream */
--card-foreground: #050404;
--popover: #FAF9F7;
--popover-foreground: #050404;
--primary: #A63D2F; /* terracotta */
--primary-foreground: #FDFCFA;
--secondary: #F5F2EE; /* parchment-deep */
--secondary-foreground: #050404;
--muted: #C5BDB4; /* warm-gray */
--muted-foreground: #2A2624; /* ink-muted */
--accent: #6E8264; /* sage */
--accent-foreground: #FDFCFA;
--destructive: #B91C1C;
--border: #C5BDB433; /* warm-gray/20 */
--input: #C5BDB433;
--ring: #A63D2F; /* terracotta */
--chart-1: #A63D2F;
--chart-2: #6E8264;
--chart-3: #C46B5E;
--chart-4: #A3B59B;
--chart-5: #2A2624;
--radius: 0.75rem;
--sidebar: #F5F2EE;
--sidebar-foreground: #050404;
--sidebar-primary: #A63D2F;
--sidebar-primary-foreground: #FDFCFA;
--sidebar-accent: #FAF9F7;
--sidebar-accent-foreground: #050404;
--sidebar-border: #C5BDB433;
--sidebar-ring: #A63D2F;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { Noto_Serif_SC, Noto_Sans_SC, Cormorant_Garamond, Geist } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
// ── 字体:用 next/font 自托管,避免 @import 阻塞渲染 ──
// Cormorant 仅含拉丁字符,display: swap 防止 FOIT
const cormorant = Cormorant_Garamond({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
style: ["normal", "italic"],
display: "swap",
variable: "--font-cormorant",
});
// Noto Serif SC / Sans SC 体积大,预连接 + swap
const notoSerif = Noto_Serif_SC({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
display: "swap",
variable: "--font-noto-serif",
preload: false,
});
const notoSans = Noto_Sans_SC({
subsets: ["latin"],
weight: ["300", "400", "500"],
display: "swap",
variable: "--font-noto-sans",
preload: false,
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: "随 · asui.xyz",
template: "%s | 随",
},
description: "胡旭的个人博客 — 记录技术探索、生活感悟与创业路上的点滴",
keywords: ["博客", "技术", "生活", "创业", "Web开发", "AI", "前端"],
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({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className={cn("h-full", notoSerif.variable, notoSans.variable, cormorant.variable, "font-sans", geist.variable)}>
<body className="min-h-full flex flex-col">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:top-3 focus:left-3 focus:px-4 focus:py-2 focus:rounded-lg focus:bg-ink focus:text-cream focus:font-sans focus:text-sm"
>
</a>
<Header />
<main id="main-content" className="flex-1">
{children}
</main>
<Footer />
</body>
</html>
);
}
+36
View File
@@ -0,0 +1,36 @@
import Link from "next/link";
export const metadata = {
title: "页面未找到",
};
export default function NotFound() {
return (
<div className="px-page max-w-5xl mx-auto pt-24 pb-32 text-center">
<p className="font-display text-7xl md:text-9xl font-light text-ink-muted/30 tracking-tight">
</p>
<h1 className="mt-8 font-display text-3xl md:text-4xl font-light text-ink">
</h1>
<p className="mt-4 font-body text-ink-muted max-w-md mx-auto leading-relaxed">
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<Link
href="/"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-ink text-cream font-sans text-sm tracking-wide hover:bg-terracotta transition-colors duration-300"
>
</Link>
<Link
href="/blog"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full border border-warm-gray/30 text-ink-muted font-sans text-sm tracking-wide hover:border-terracotta/40 hover:text-terracotta transition-colors duration-300"
>
</Link>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { getPublishedPosts } from "@/lib/store";
import HeroSection from "@/components/HeroSection";
import { FeaturedGrid, RecentList } from "@/components/PostSections";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const posts = await getPublishedPosts();
const featuredPosts = posts.filter((p) => p.featured);
const recentPosts = posts.slice(0, 5);
return (
<>
<HeroSection />
<FeaturedGrid posts={featuredPosts} />
<RecentList posts={recentPosts} />
</>
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPostBySlug, getPublishedPosts } from "@/lib/store";
import PostContent from "@/components/PostContent";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
const url = `${SITE_URL}/posts/${post.slug}`;
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: url },
openGraph: {
title: post.title,
description: post.excerpt,
url,
type: "article",
publishedTime: post.date,
authors: ["胡旭"],
tags: post.tags,
siteName: "随 · asui.xyz",
locale: "zh_CN",
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
const all = await getPublishedPosts();
const currentIndex = all.findIndex((p) => p.slug === slug);
const prevPost = currentIndex > 0 ? all[currentIndex - 1] : null;
const nextPost = currentIndex < all.length - 1 ? all[currentIndex + 1] : null;
// BlogPosting 结构化数据,利于搜索引擎富摘要
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.updatedAt,
author: { "@type": "Person", name: "胡旭", url: SITE_URL },
publisher: { "@type": "Person", name: "胡旭" },
mainEntityOfPage: `${SITE_URL}/posts/${post.slug}`,
keywords: post.tags.join(", "),
};
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<PostContent post={post} prevPost={prevPost} nextPost={nextPost} />
</>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin", "/api"],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}
+49
View File
@@ -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;
}
}
+72
View File
@@ -0,0 +1,72 @@
import { getAllTags } from "@/lib/store";
import GsapReveal from "@/components/GsapReveal";
export const dynamic = "force-dynamic";
export const metadata = {
title: "标签",
description: "按标签浏览博客文章",
};
export default async function TagsPage() {
const allTags = await getAllTags();
const maxCount = allTags.length > 0 ? Math.max(...allTags.map((t) => t.count)) : 1;
function getTagSize(count: number) {
const ratio = count / maxCount;
if (ratio >= 0.8) return "text-xl md:text-2xl font-medium";
if (ratio >= 0.5) return "text-lg md:text-xl";
if (ratio >= 0.3) return "text-base md:text-lg";
return "text-sm md:text-base";
}
function getTagWeight(count: number) {
return count / maxCount >= 0.5 ? "text-ink" : "text-ink-muted";
}
return (
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
<GsapReveal variant="fade-up" className="mb-14">
<h1 className="font-display text-4xl md:text-5xl font-light text-ink tracking-tight">
</h1>
<p className="mt-3 font-body text-ink-muted max-w-md">
{allTags.length} 使
</p>
</GsapReveal>
{allTags.length > 0 ? (
<GsapReveal variant="scale" stagger={0.04} className="max-w-3xl">
<div className="flex flex-wrap gap-3 md:gap-4 items-center">
{allTags.map((tag) => (
<a
key={tag.name}
href={`/blog?tag=${encodeURIComponent(tag.name)}`}
className="group inline-flex items-center gap-1.5 px-4 py-2 rounded-full border border-warm-gray/15 bg-cream hover:border-terracotta hover:bg-terracotta/5 transition-all duration-300"
>
<span className={`font-display ${getTagSize(tag.count)} ${getTagWeight(tag.count)} group-hover:text-terracotta transition-colors duration-300`}>
{tag.name}
</span>
<span className="font-sans text-xs text-ink-muted group-hover:text-terracotta transition-colors duration-300">
{tag.count}
</span>
</a>
))}
</div>
</GsapReveal>
) : (
<div className="py-24 text-center">
<p className="font-display text-2xl text-ink-muted mb-2"></p>
</div>
)}
<GsapReveal variant="fade-in" className="mt-20">
<div className="divider-ornament">
<span className="font-display text-sm italic whitespace-nowrap">
</span>
</div>
</GsapReveal>
</div>
);
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { PublicPost } from "@/lib/store";
import { formatDate, readingTimeLabel } from "@/lib/utils";
import { useGsapAnimation } from "./useGsapAnimation";
import { ChevronLeft, ChevronRight } from "lucide-react";
gsap.registerPlugin(ScrollTrigger);
interface BlogListProps {
posts: PublicPost[];
}
export default function BlogList({ posts }: BlogListProps) {
const router = useRouter();
const searchParams = useSearchParams();
const activeCategory = searchParams.get("category") || "";
const activeTag = searchParams.get("tag") || "";
const page = Number(searchParams.get("page")) || 1;
const pageSize = 10;
const headerRef = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
if (isReduced) return;
gsap.from(".blog-header-el", {
y: 30,
opacity: 0,
duration: 0.7,
stagger: 0.1,
ease: "power3.out",
});
}, []);
const listRef = useGsapAnimation<HTMLDivElement>((scope, isReduced) => {
if (isReduced) return;
gsap.from(".blog-list-item", {
y: 40,
opacity: 0,
duration: 0.7,
stagger: 0.08,
ease: "power3.out",
scrollTrigger: { trigger: scope, start: "top 88%" },
});
}, [activeCategory, activeTag]);
// 从文章动态聚合分类与标签
const { categories, tags } = useMemo(() => {
const catSet = new Map<string, number>();
const tagSet = new Map<string, number>();
for (const p of posts) {
catSet.set(p.category, (catSet.get(p.category) || 0) + 1);
for (const t of p.tags) tagSet.set(t, (tagSet.get(t) || 0) + 1);
}
return {
categories: [...catSet.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name),
tags: [...tagSet.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name),
};
}, [posts]);
const filtered = useMemo(() => {
return posts.filter((p) => {
if (activeCategory && p.category !== activeCategory) return false;
if (activeTag && !p.tags.includes(activeTag)) return false;
return true;
});
}, [posts, activeCategory, activeTag]);
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 });
}
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 (
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
{/* Header */}
<div ref={headerRef as React.RefObject<HTMLDivElement>} className="mb-10">
<h1 className="blog-header-el font-display text-4xl md:text-5xl font-light text-ink tracking-tight">
</h1>
<p className="blog-header-el mt-3 font-body text-ink-muted max-w-md">
</p>
</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 */}
{paged.length > 0 ? (
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
{paged.map((post) => (
<Link key={post.slug} href={`/posts/${post.slug}`} className="blog-list-item group block">
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-xl transition-all duration-300">
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-6">
<div className="shrink-0 md:w-36 md:pt-1">
<time className="font-sans text-sm text-ink-muted tabular-nums">
{formatDate(post.date)}
</time>
<div className="mt-1 flex items-center gap-2 md:flex-col md:items-start md:gap-1">
<span className="font-sans text-sm text-terracotta">{post.category}</span>
<span className="hidden md:block font-sans text-sm text-ink-muted">
{readingTimeLabel(post.readingTime)}
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h2 className="font-display text-xl md:text-2xl font-medium text-ink group-hover:text-terracotta transition-colors duration-300 leading-snug">
{post.title}
</h2>
<p className="mt-2 font-body text-base text-ink-muted leading-relaxed line-clamp-2 max-w-xl">
{post.excerpt}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{post.tags.slice(0, 4).map((tag) => (
<span key={tag} className="font-sans text-xs px-2.5 py-0.5 rounded-full bg-parchment-deep text-ink-muted">
{tag}
</span>
))}
</div>
</div>
{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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-terracotta">
<path d="M7 12h10M13 8l4 4-4 4" />
</svg>
</div>
</div>
</article>
</Link>
))}
</div>
) : (
<div className="py-24 text-center">
<p className="font-display text-2xl text-ink-muted mb-2"></p>
<p className="font-sans text-sm text-ink-muted"></p>
</div>
)}
{/* 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>
);
}
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>
);
}
+121
View File
@@ -0,0 +1,121 @@
import Link from "next/link";
export default function Footer() {
return (
<footer className="mt-auto border-t border-warm-gray/20 bg-parchment-deep/50">
<div className="mx-auto px-page max-w-5xl py-12">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-8">
{/* Left - brand */}
<div>
<Link
href="/"
className="font-display text-xl font-semibold text-ink hover:text-terracotta transition-colors duration-300"
>
· asui.xyz
</Link>
<p className="mt-2 font-sans text-sm text-ink-muted max-w-xs leading-relaxed">
</p>
</div>
{/* Right - links */}
<div className="flex gap-8">
<div>
<h4 className="font-sans text-xs text-ink-muted tracking-widest uppercase mb-3">
</h4>
<div className="flex flex-col gap-2">
<Link
href="/blog"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
</Link>
<Link
href="/categories"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
</Link>
<Link
href="/tags"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
</Link>
<Link
href="/about"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
</Link>
</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>
<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>
{/* Bottom */}
<div className="mt-10 pt-6 border-t border-warm-gray/15 flex flex-col sm:flex-row items-center justify-between gap-2">
<p className="font-sans text-xs text-ink-muted">
&copy; {new Date().getFullYear()} . All rights reserved.
</p>
<div className="flex items-center gap-4 font-sans text-xs text-ink-muted">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
className="hover:text-terracotta transition-colors duration-300"
>
ICP备2024032972号-3
</a>
<span>
Powered by <span className="text-terracotta">Next.js</span>
</span>
</div>
</div>
</div>
</footer>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
interface GsapRevealProps {
children: React.ReactNode;
variant?: "fade-up" | "fade-in" | "slide-left" | "slide-right" | "scale";
delay?: number;
duration?: number;
stagger?: number;
className?: string;
once?: boolean;
}
const VARIANTS = {
"fade-up": { y: 40, opacity: 0 },
"fade-in": { opacity: 0 },
"slide-left": { x: -40, opacity: 0 },
"slide-right": { x: 40, opacity: 0 },
scale: { scale: 0.92, opacity: 0 },
} as const;
export default function GsapReveal({
children,
variant = "fade-up",
delay = 0,
duration = 0.8,
stagger = 0,
className = "",
once = true,
}: GsapRevealProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// 关键降级:尊重用户系统设置,无障碍优先,不做任何位移。
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) return;
const targets = el.children.length > 1 ? Array.from(el.children) : [el];
const ctx = gsap.context(() => {
if (stagger > 0 && targets.length > 1) {
targets.forEach((child, i) => {
gsap.from(child, {
...VARIANTS[variant],
duration,
delay: delay + i * stagger,
ease: "power3.out",
scrollTrigger: {
trigger: child,
start: "top 92%",
toggleActions: once ? "play none none none" : "play none none reverse",
},
});
});
} else {
gsap.from(targets, {
...VARIANTS[variant],
duration,
delay,
ease: "power3.out",
scrollTrigger: {
trigger: el,
start: "top 88%",
toggleActions: once ? "play none none none" : "play none none reverse",
},
});
}
}, el);
return () => ctx.revert();
}, [variant, delay, duration, stagger, once]);
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
const navItems = [
{ label: "首页", href: "/" },
{ label: "文章", href: "/blog" },
{ label: "分类", href: "/categories" },
{ label: "标签", href: "/tags" },
{ label: "关于", href: "/about" },
];
export default function Header() {
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
return (
<header className="sticky top-0 z-50 backdrop-blur-md bg-parchment/80 border-b border-warm-gray/20">
<div className="mx-auto px-page max-w-5xl">
<nav className="flex items-center justify-between h-16" aria-label="主导航">
{/* Logo */}
<Link href="/" className="group flex items-center gap-2">
<span className="font-display text-2xl font-semibold tracking-wide text-ink group-hover:text-terracotta transition-colors duration-300">
</span>
<span className="hidden sm:inline font-sans text-xs text-ink-muted tracking-widest uppercase">
asui.xyz
</span>
</Link>
{/* Desktop Nav */}
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={`
relative px-4 py-2 font-sans text-sm tracking-wide transition-colors duration-300
${isActive ? "text-terracotta" : "text-ink-muted hover:text-ink"}
`}
>
{item.label}
{isActive && (
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-terracotta" />
)}
</Link>
);
})}
</div>
{/* Mobile menu button */}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="md:hidden p-2 text-ink-muted hover:text-ink transition-colors"
aria-label="切换菜单"
aria-expanded={menuOpen}
aria-controls="mobile-menu"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
{menuOpen ? (
<>
<line x1="4" y1="4" x2="16" y2="16" />
<line x1="16" y1="4" x2="4" y2="16" />
</>
) : (
<>
<line x1="3" y1="6" x2="17" y2="6" />
<line x1="3" y1="10" x2="17" y2="10" />
<line x1="3" y1="14" x2="17" y2="14" />
</>
)}
</svg>
</button>
</nav>
{/* Mobile menu — 用 grid-rows 过渡实现展开/收起动画 */}
<div
id="mobile-menu"
className="md:hidden grid transition-all duration-300 ease-out"
style={{ gridTemplateRows: menuOpen ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="pb-4">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMenuOpen(false)}
aria-current={isActive ? "page" : undefined}
className={`
block py-3 px-2 font-sans text-sm tracking-wide border-b border-warm-gray/10 transition-colors duration-300
${isActive ? "text-terracotta" : "text-ink-muted"}
`}
>
{item.label}
</Link>
);
})}
</div>
</div>
</div>
</div>
</header>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import Link from "next/link";
import gsap from "gsap";
import { useGsapAnimation } from "./useGsapAnimation";
export default function HeroSection() {
const ref = useGsapAnimation<HTMLElement>((scope, isReduced) => {
if (isReduced) return;
const tl = gsap.timeline({ delay: 0.2 });
tl.from(".hero-subtitle", { y: 20, opacity: 0, duration: 0.6, ease: "power3.out" });
const heading = scope.querySelector<HTMLElement>("[data-heading]");
if (heading) {
const spans = heading.querySelectorAll(".hero-char");
tl.from(
spans,
{
y: 40,
opacity: 0,
filter: "blur(6px)",
duration: 0.6,
stagger: 0.035,
ease: "power3.out",
},
"-=0.3"
);
}
tl.from(".hero-desc", { y: 20, opacity: 0, duration: 0.6, ease: "power3.out" }, "-=0.3");
tl.from(".hero-btn", { y: 15, opacity: 0, duration: 0.5, stagger: 0.1, ease: "power3.out" }, "-=0.3");
tl.from(".hero-divider", { scaleX: 0, opacity: 0, duration: 0.8, ease: "power2.inOut" });
}, []);
// 逐字拆分标题
const headingChars = (text: string) =>
[...text].map((char, i) => (
<span key={i} className="hero-char inline-block" style={char === " " ? { width: "0.3em" } : undefined}>
{char}
</span>
));
return (
<section ref={ref as React.RefObject<HTMLElement>} className="px-page max-w-5xl mx-auto pt-20 pb-16 md:pt-28 md:pb-24">
<div className="max-w-2xl">
<p className="hero-subtitle font-sans text-sm tracking-widest text-ink-muted uppercase mb-6">
Sui的个人Blog
</p>
<h1
data-heading
className="font-display text-4xl md:text-6xl font-light text-ink leading-tight tracking-tight"
>
{headingChars("写字,")}
<br />
{/* 仅高亮关键词「思考」,避免整句赭红造成视觉重量过重 */}
<span>
{headingChars("是一种 ")}
<span className="text-terracotta">{headingChars("思考")}</span>
{headingChars(" 的方式")}
</span>
</h1>
<p className="hero-desc mt-6 font-body text-lg text-ink-muted leading-relaxed max-w-lg">
</p>
<div className="mt-8 flex flex-wrap items-center gap-4">
<Link
href="/blog"
className="hero-btn inline-flex items-center gap-2 px-6 py-3 rounded-full bg-ink text-cream font-sans text-sm tracking-wide hover:bg-terracotta transition-colors duration-300"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 8h8M9 5l3 3-3 3" />
</svg>
</Link>
<Link
href="/about"
className="hero-btn 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>
{/* 装饰分隔线 */}
<div className="hero-divider mt-20 flex items-center gap-4 text-warm-gray origin-center">
<div className="h-px flex-1 bg-gradient-to-r from-warm-gray/20 to-transparent" />
<span className="font-display text-sm italic"></span>
<div className="h-px flex-1 bg-gradient-to-l from-warm-gray/20 to-transparent" />
</div>
</section>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import Link from "next/link";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { PublicPost } from "@/lib/store";
import { formatDate, readingTimeLabel } from "@/lib/utils";
import { useGsapAnimation } from "./useGsapAnimation";
gsap.registerPlugin(ScrollTrigger);
export default function PostContent({
post,
prevPost,
nextPost,
}: {
post: PublicPost;
prevPost: PublicPost | null;
nextPost: PublicPost | null;
}) {
const ref = useGsapAnimation<HTMLElement>((scope, isReduced) => {
if (isReduced) return;
const tl = gsap.timeline();
tl.from(".post-back", { x: -20, opacity: 0, duration: 0.5, ease: "power3.out" });
tl.from(".post-category", { y: 15, opacity: 0, duration: 0.5, ease: "power3.out" }, "-=0.2");
// 标题逐字 —— 用 scope 内选择器,而非全局 document
const titleChars = scope.querySelectorAll(".post-title-char");
tl.from(
titleChars,
{ y: 30, opacity: 0, filter: "blur(4px)", duration: 0.5, stagger: 0.03, ease: "power3.out" },
"-=0.3"
);
tl.from(".post-meta", { y: 10, opacity: 0, duration: 0.4, ease: "power3.out" }, "-=0.2");
tl.from(".post-divider-line", { scaleX: 0, duration: 0.6, ease: "power2.inOut", stagger: 0.1 }, "-=0.2");
tl.from(".post-divider-dot", { scale: 0, duration: 0.3, ease: "back.out(2)" }, "-=0.3");
// 正文段落滚动揭示
const paragraphs = scope.querySelectorAll(".prose-literary > *");
paragraphs.forEach((p) => {
gsap.from(p, {
y: 25,
opacity: 0,
duration: 0.6,
ease: "power3.out",
scrollTrigger: { trigger: p, start: "top 90%" },
});
});
gsap.from(".post-tag", {
scale: 0.8,
opacity: 0,
duration: 0.4,
stagger: 0.05,
ease: "back.out(1.5)",
scrollTrigger: { trigger: ".post-tags", start: "top 90%" },
});
gsap.from(".post-nav", {
y: 20,
opacity: 0,
duration: 0.5,
stagger: 0.1,
ease: "power3.out",
scrollTrigger: { trigger: ".post-navs", start: "top 90%" },
});
}, [post.slug]);
// 标题拆字
const titleChars = [...post.title].map((char, i) => (
<span key={i} className="post-title-char inline-block" style={char === " " ? { width: "0.3em" } : undefined}>
{char}
</span>
));
return (
<article ref={ref as React.RefObject<HTMLElement>} className="px-page max-w-5xl mx-auto pt-12 pb-24">
{/* Back link */}
<div className="post-back mb-10">
<Link
href="/blog"
className="inline-flex items-center gap-2 font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 4l-4 4 4 4" />
</svg>
</Link>
</div>
{/* Header */}
<header className="max-w-2xl mx-auto text-center mb-14">
<span className="post-category inline-block font-sans text-sm tracking-widest text-terracotta uppercase mb-4">
{post.category}
</span>
<h1 className="font-display text-3xl md:text-5xl font-light text-ink leading-tight tracking-tight">
{titleChars}
</h1>
<div className="post-meta mt-6 flex items-center justify-center gap-3 font-sans text-sm text-ink-muted">
<time>{formatDate(post.date)}</time>
<span className="w-1 h-1 rounded-full bg-warm-gray" />
<span>{readingTimeLabel(post.readingTime)}</span>
</div>
</header>
{/* Cover image */}
{post.coverImage && (
<div className="max-w-3xl mx-auto mb-14">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={post.coverImage}
alt={post.title}
className="w-full rounded-2xl object-cover max-h-[480px]"
/>
</div>
)}
{/* Decorative divider */}
<div className="max-w-2xl mx-auto mb-14">
<div className="flex items-center justify-center gap-3">
<div className="post-divider-line w-8 h-px bg-warm-gray origin-right" />
<div className="post-divider-dot w-2 h-2 rounded-full border border-terracotta" />
<div className="post-divider-line w-8 h-px bg-warm-gray origin-left" />
</div>
</div>
{/* Content — 已在 store 写入时净化,渲染时再次净化以防御历史脏数据 */}
<div
className="max-w-2xl mx-auto prose-literary"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
{/* Tags — 点击跳转到 /blog 按标签筛选 */}
<div className="post-tags max-w-2xl mx-auto mt-14 pt-8 border-t border-warm-gray/10">
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag}
href={`/blog?tag=${encodeURIComponent(tag)}`}
className="post-tag font-sans text-xs px-3 py-1.5 rounded-full border border-warm-gray/20 text-ink-muted hover:border-terracotta/30 hover:text-terracotta transition-colors duration-300"
>
#{tag}
</Link>
))}
</div>
</div>
{/* Prev / Next navigation */}
<div className="post-navs max-w-2xl mx-auto mt-14 grid grid-cols-1 md:grid-cols-2 gap-4">
{prevPost ? (
<Link
href={`/posts/${prevPost.slug}`}
className="post-nav group p-5 rounded-xl border border-warm-gray/10 hover:border-terracotta/20 hover:bg-cream transition-all duration-300"
>
<span className="font-sans text-xs text-ink-muted block mb-1"></span>
<span className="font-display text-base text-ink group-hover:text-terracotta transition-colors duration-300">
{prevPost.title}
</span>
</Link>
) : (
<div />
)}
{nextPost ? (
<Link
href={`/posts/${nextPost.slug}`}
className="post-nav group p-5 rounded-xl border border-warm-gray/10 hover:border-terracotta/20 hover:bg-cream transition-all duration-300 text-right"
>
<span className="font-sans text-xs text-ink-muted block mb-1"></span>
<span className="font-display text-base text-ink group-hover:text-terracotta transition-colors duration-300">
{nextPost.title}
</span>
</Link>
) : (
<div />
)}
</div>
</article>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import Link from "next/link";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { PublicPost } from "@/lib/store";
import { formatDate, readingTimeLabel } from "@/lib/utils";
import { useGsapAnimation } from "./useGsapAnimation";
gsap.registerPlugin(ScrollTrigger);
function FeaturedCard({ post }: { post: PublicPost }) {
return (
<Link href={`/posts/${post.slug}`} className="group block featured-card">
<article className="relative p-7 md:p-10 rounded-2xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 hover:shadow-lg hover:shadow-terracotta/5 transition-all duration-500">
<span className="inline-block font-sans text-sm tracking-widest text-terracotta uppercase mb-4">
{post.category}
</span>
<h3 className="font-display text-2xl md:text-3xl font-medium text-ink leading-snug group-hover:text-terracotta transition-colors duration-300 mb-4">
{post.title}
</h3>
<p className="font-body text-base text-ink-muted leading-relaxed line-clamp-2 mb-5">
{post.excerpt}
</p>
<div className="flex items-center gap-3 font-sans text-sm text-ink-muted">
<time>{formatDate(post.date)}</time>
<span className="w-1 h-1 rounded-full bg-warm-gray" />
<span>{readingTimeLabel(post.readingTime)}</span>
</div>
<div className="absolute top-7 right-7 md:top-10 md:right-10 opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-terracotta">
<path d="M5 10h10M11 6l4 4-4 4" />
</svg>
</div>
</article>
</Link>
);
}
export function FeaturedGrid({ posts }: { posts: PublicPost[] }) {
const ref = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
if (isReduced || posts.length === 0) return;
gsap.from(".featured-card", {
y: 50,
opacity: 0,
duration: 0.8,
stagger: 0.15,
ease: "power3.out",
scrollTrigger: { trigger: ref.current, start: "top 85%" },
});
}, [posts.length]);
if (posts.length === 0) return null;
return (
<section ref={ref as React.RefObject<HTMLDivElement>} className="px-page max-w-5xl mx-auto pb-16">
<div className="grid md:grid-cols-2 gap-5">
{posts.map((post) => (
<FeaturedCard key={post.slug} post={post} />
))}
</div>
</section>
);
}
export function RecentList({ posts }: { posts: PublicPost[] }) {
const ref = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
if (isReduced || posts.length === 0) return;
gsap.from(".recent-item", {
y: 30,
opacity: 0,
duration: 0.6,
stagger: 0.08,
ease: "power3.out",
scrollTrigger: { trigger: ref.current, start: "top 85%" },
});
}, [posts.length]);
if (posts.length === 0) return null;
return (
<section ref={ref as React.RefObject<HTMLDivElement>} className="px-page max-w-5xl mx-auto pb-24">
<div className="divider-ornament mb-10">
<span className="font-display text-sm italic whitespace-nowrap"></span>
</div>
<div className="space-y-0">
{posts.map((post) => (
<Link key={post.slug} href={`/posts/${post.slug}`} className="recent-item group block">
<article className="flex items-baseline gap-6 py-7 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-lg transition-all duration-300">
<time className="shrink-0 font-sans text-sm text-ink-muted tabular-nums w-28 pt-0.5">
{formatDate(post.date)}
</time>
<div className="flex-1 min-w-0">
<h3 className="font-display text-xl font-medium text-ink group-hover:text-terracotta transition-colors duration-300 truncate">
{post.title}
</h3>
<p className="mt-1.5 font-body text-base text-ink-muted line-clamp-1">
{post.excerpt}
</p>
</div>
<span className="hidden sm:inline-block shrink-0 font-sans text-sm text-ink-muted tracking-wide">
{post.category}
</span>
</article>
</Link>
))}
</div>
<div className="mt-10 text-center">
<Link
href="/blog"
className="inline-flex items-center gap-2 font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 7h8M8 4l3 3-3 3" />
</svg>
</Link>
</div>
</section>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { createContext, useCallback, useContext, useState } from "react";
import { X } from "lucide-react";
type ToastType = "success" | "error" | "info";
interface Toast {
id: number;
type: ToastType;
message: string;
}
const ToastContext = createContext<{
toast: (message: string, type?: ToastType) => void;
}>({ toast: () => {} });
let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const toast = useCallback((message: string, type: ToastType = "info") => {
const id = nextId++;
setToasts((prev) => [...prev, { id, type, message }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3500);
}, []);
function dismiss(id: number) {
setToasts((prev) => prev.filter((t) => t.id !== id));
}
return (
<ToastContext value={{ toast }}>
{children}
<div
aria-live="assertive"
role="alert"
className="fixed bottom-6 right-6 z-[200] flex flex-col gap-2 pointer-events-none"
>
{toasts.map((t) => (
<div
key={t.id}
className={`pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg font-sans text-sm animate-[toast-in_0.3s_ease-out] ${
t.type === "success"
? "bg-accent text-accent-foreground"
: t.type === "error"
? "bg-red-600 text-white"
: "bg-foreground text-background"
}`}
>
<span className="flex-1">{t.message}</span>
<button
onClick={() => dismiss(t.id)}
className="shrink-0 opacity-70 hover:opacity-100 transition-opacity"
aria-label="关闭"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</ToastContext>
);
}
export function useToast() {
return useContext(ToastContext);
}
/**
* 带自动错误处理的安全 fetch。
* 非 2xx 响应自动弹出错误 toast 并抛出异常,调用方无需自行处理。
*/
export async function safeFetch(
url: string,
options: RequestInit | undefined,
toast: (msg: string, type?: ToastType) => void
): Promise<Response> {
const res = await fetch(url, options);
if (!res.ok) {
let msg = `请求失败 (${res.status})`;
try {
const body = await res.clone().json();
if (body.error) msg = body.error;
if (body.issues) msg += `${body.issues.join("")}`;
} catch {
/* 非 JSON 响应,用默认消息 */
}
toast(msg, "error");
throw new Error(msg);
}
return res;
}
+444
View File
@@ -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>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface ConfirmDialogProps {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
title: string;
description: string;
confirmText?: string;
variant?: "default" | "destructive";
}
export default function ConfirmDialog({
open,
onConfirm,
onCancel,
title,
description,
confirmText = "确认",
variant = "default",
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
</Button>
<Button
variant={variant === "destructive" ? "destructive" : "default"}
onClick={onConfirm}
>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+117
View File
@@ -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, "![$2]($1)");
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, "![]($1)");
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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/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>
);
}
+517
View File
@@ -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]);
// ── 自动保存到 localStoragedebounce 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>
);
}
+310
View File
@@ -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>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+38
View File
@@ -0,0 +1,38 @@
"use client"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
/**
* GSAP 动画的统一入口。
*
* 核心原则:
* 1. 尊重 prefers-reduced-motion —— 开启时直接跳过动画,内容保持可见。
* 2. 在 gsap.context 内执行,卸载时自动 revert,避免泄露。
*
* 用法:
* const ref = useGsapAnimation<HTMLElement>((scope, isReduced) => {
* if (isReduced) return;
* gsap.from(".item", { y: 40, opacity: 0, stagger: 0.1, scrollTrigger: {...} });
* });
*/
export function useGsapAnimation<T extends HTMLElement = HTMLElement>(
setup: (scope: T, isReduced: boolean) => void,
deps: unknown[] = []
) {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const isReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => setup(el, isReduced), el);
return () => ctx.revert();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return ref;
}
+124
View File
@@ -0,0 +1,124 @@
import type { Post } from "@/lib/store";
/**
* 种子数据来源。仅用于首次初始化(见 src/lib/seed.ts)。
* 前台/后台一律从 store.ts 读取,不再直接 import 本文件。
*
* 这里只声明可由用户填写的字段,id/createdAt/updatedAt 由 store 生成。
*/
export type SeedPost = Omit<Post, "id" | "createdAt" | "updatedAt">;
export const seedPosts: SeedPost[] = [
{
slug: "on-writing-and-silence",
title: "论写作与沉默",
excerpt: "有些话适合写在纸上,有些话适合留在风里。写作不是填满空白的过程,而是从空白中提炼意义的旅程。",
content: `<p>有些话适合写在纸上,有些话适合留在风里。</p><p>我常常觉得,沉默是一种被低估的能力。在这个信息过载的时代,我们急于表达、急于分享,却很少给自己留出沉默的空间。写作不是填满空白的过程,而是从空白中提炼意义的旅程。</p><p>每一次落笔,都是一次与自己的对话。那些在深夜里涌现的念头,像潮水一样涌来,又像退潮后的贝壳,最终留下的才是最珍贵的。</p><p>我开始学会在写作之前先沉默。让想法在脑海中沉淀,让语言在时间里发酵。好的文字从来不是急出来的。</p>`,
date: "2026-06-15",
category: "随笔",
tags: ["写作", "思考", "生活哲学"],
readingTime: 4,
featured: true,
status: "published",
},
{
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",
},
{
slug: "notes-on-digital-twin",
title: "数字孪生笔记:从3D建模到Web可视化",
excerpt: "从 Three.js 到 React Three FiberWeb 3D 的门槛比想象中低很多,但要做好,需要理解的东西远不止代码。",
content: `<p>从 Three.js 到 React Three FiberWeb 3D 的门槛比想象中低很多。</p><p>但要做好,需要理解的东西远不止代码。光照、材质、相机、性能优化,每一个都是深坑。这篇文章记录我在数字孪生项目中的一些实践和思考。</p>`,
date: "2026-06-05",
category: "技术",
tags: ["Web3D", "React", "Three.js", "前端"],
readingTime: 8,
featured: false,
status: "published",
},
{
slug: "reading-list-spring",
title: "春日书单:五本改变我看世界方式的书",
excerpt: "春天适合读一些柔软的书。不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。",
content: `<p>春天适合读一些柔软的书。</p><p>不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。以下五本书,在这个春天给了我很多安静的力量。</p>`,
date: "2026-05-28",
category: "阅读",
tags: ["阅读", "书单", "生活"],
readingTime: 5,
featured: true,
status: "published",
},
{
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",
},
{
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",
},
{
slug: "rainy-day-thoughts",
title: "雨天杂记",
excerpt: "六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。泡一壶六安瓜片,坐在窗前看雨,什么都不想。",
content: `<p>六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。</p><p>泡一壶六安瓜片,坐在窗前看雨,什么都不想。这种无所事事的下午,反而是一周中最有创造力的时刻。</p>`,
date: "2026-05-05",
category: "随笔",
tags: ["随笔", "生活", "六安"],
readingTime: 3,
featured: false,
status: "published",
},
{
slug: "next-js-blog-from-scratch",
title: "从零搭建一个博客系统",
excerpt: "为什么选择 Next.js 来搭建?为什么不用 WordPress?这篇文章聊聊技术选型的思考过程。",
content: `<p>为什么选择 Next.js 来搭建?为什么不用 WordPress</p><p>作为一个前端开发者,我希望能完全掌控博客的UI和交互体验。Next.js 的 App Router 与 Server Components 让我可以自由设计前端展示,同时保持良好的性能。数据则存储在本地的 JSON 文件中,简单而透明。</p>`,
date: "2026-04-28",
category: "技术",
tags: ["Next.js", "博客", "前端"],
readingTime: 6,
featured: false,
status: "published",
},
];
export const seedCategories = [
{ name: "技术", description: "代码、架构与技术探索" },
{ name: "随笔", description: "生活感悟与碎片思考" },
{ name: "旅行", description: "在路上看到的风景与人" },
{ name: "阅读", description: "书中世界与阅读心得" },
{ name: "创业", description: "产品思考与创业记录" },
];
export const seedTags = [
"写作", "思考", "生活哲学", "旅行", "自然", "六安",
"Web3D", "React", "Three.js", "前端", "阅读", "书单",
"生活", "AI", "Stable Diffusion", "Apple Silicon",
"创业", "灯箱", "产品", "sui_lightbox", "随笔",
"Next.js", "博客",
];
+70
View File
@@ -0,0 +1,70 @@
import { cookies } from "next/headers";
/**
* 认证相关常量与校验函数。
*
* 会话采用 HMAC 签名的 tokenpayload + "." + signature),服务端校验签名,
* 避免使用固定字符串 cookie 被伪造。签名密钥来自环境变量,缺失时回退到
* 开发用默认值(仅用于本地)。
*/
const SESSION_KEY = "admin_session";
function getSecret(): string {
return process.env.SESSION_SECRET || "dev-only-insecure-secret-change-me";
}
/**
* 用 Web Crypto API 计算字符串的 HMAC-SHA256,返回 base64url。
* 不依赖外部库,Node 18+ 与 edge runtime 均可用。
*/
async function hmac(message: string, secret: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
const bytes = new Uint8Array(sig);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** 签发一个带过期时间的会话 token。 */
export async function createSession(maxAgeSeconds: number): Promise<string> {
const payload = JSON.stringify({
v: "1",
exp: Date.now() + maxAgeSeconds * 1000,
});
const b64 = btoa(unescape(encodeURIComponent(payload)));
const sig = await hmac(b64, getSecret());
return `${b64}.${sig}`;
}
/** 校验 token 签名与过期时间,通过返回 true。 */
async function verifySession(token: string): Promise<boolean> {
const [b64, sig] = token.split(".");
if (!b64 || !sig) return false;
const expected = await hmac(b64, getSecret());
if (sig !== expected) return false;
try {
const payload = JSON.parse(decodeURIComponent(escape(atob(b64))));
return typeof payload.exp === "number" && payload.exp > Date.now();
} catch {
return false;
}
}
/** 在 Server Component / Route Handler 中检查当前是否已登录。 */
export async function checkAuth(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_KEY)?.value;
if (!token) return false;
return verifySession(token);
}
export { SESSION_KEY };
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { checkAuth } from "./auth";
/** 统一的未授权响应。 */
export function unauthorized() {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
/** 解析并校验请求体,失败时返回 422 响应(由调用方 return)。 */
export async function parseBody<T>(
request: Request,
schema: { parse: (d: unknown) => T }
): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
try {
const json = await request.json();
const data = schema.parse(json);
return { ok: true, data };
} catch (err) {
if (err instanceof ZodError) {
return {
ok: false,
response: NextResponse.json(
{ error: "输入校验失败", issues: err.issues.map((i) => i.message) },
{ status: 422 }
),
};
}
return {
ok: false,
response: NextResponse.json({ error: "请求格式错误" }, { status: 400 }),
};
}
}
/** 在受保护路由开头做鉴权,未通过则返回 401 响应。 */
export async function requireAuth(): Promise<NextResponse | null> {
if (!(await checkAuth())) return unauthorized();
return null;
}
+45
View File
@@ -0,0 +1,45 @@
/**
* 极简的内存级速率限制,针对登录接口防暴力破解。
*
* 注:基于进程内存,多实例部署下不共享;对单实例个人博客足够。
* 生产环境若需更强保护,可换用 Redis 或 upstash ratelimit。
*/
const attempts = new Map<string, { count: number; lockedUntil: number }>();
const MAX_ATTEMPTS = 5;
const LOCK_MS = 15 * 60 * 1000; // 锁定 15 分钟
/** 记录一次失败尝试;返回当前是否已被锁定。 */
export function registerFailedAttempt(key: string): { locked: boolean; retryAfterSec: number } {
const now = Date.now();
const entry = attempts.get(key);
if (entry && entry.lockedUntil > now) {
return { locked: true, retryAfterSec: Math.ceil((entry.lockedUntil - now) / 1000) };
}
const count = entry && entry.lockedUntil > 0 ? entry.count + 1 : 1;
if (count >= MAX_ATTEMPTS) {
attempts.set(key, { count, lockedUntil: now + LOCK_MS });
return { locked: true, retryAfterSec: Math.ceil(LOCK_MS / 1000) };
}
attempts.set(key, { count, lockedUntil: 0 });
return { locked: false, retryAfterSec: 0 };
}
/** 登录成功后清除记录。 */
export function clearAttempts(key: string): void {
attempts.delete(key);
}
/** 检查 key 是否仍处于锁定状态。 */
export function isLocked(key: string): { locked: boolean; retryAfterSec: number } {
const entry = attempts.get(key);
const now = Date.now();
if (entry && entry.lockedUntil > now) {
return { locked: true, retryAfterSec: Math.ceil((entry.lockedUntil - now) / 1000) };
}
return { locked: false, retryAfterSec: 0 };
}
+34
View File
@@ -0,0 +1,34 @@
import sanitize from "sanitize-html";
/**
* 净化用户提交的 HTML 内容,移除脚本、事件处理器等危险标签,
* 但保留博客正文所需的语义化标签与 class(用于 prose 排版)。
*/
const SANITIZE_CONFIG: sanitize.IOptions = {
allowedTags: [
"p", "br", "hr", "span", "div", "section", "article",
"h1", "h2", "h3", "h4", "h5", "h6",
"strong", "b", "em", "i", "u", "s", "del", "ins", "mark", "small", "sub", "sup",
"blockquote", "q", "cite",
"ul", "ol", "li", "dl", "dt", "dd",
"a", "img",
"pre", "code",
"table", "thead", "tbody", "tr", "th", "td",
"figure", "figcaption",
"abbr", "address", "time", "kbd", "var", "samp",
],
allowedAttributes: {
"a": ["href", "target", "rel"],
"img": ["src", "alt", "title", "width", "height"],
"*": ["class", "datetime", "cite", "lang", "dir"],
"td": ["colspan", "rowspan"],
"th": ["colspan", "rowspan"],
},
allowedSchemes: ["http", "https", "mailto"],
disallowedTagsMode: "discard",
};
export function sanitizeHtml(dirty: string): string {
if (!dirty) return "";
return sanitize(dirty, SANITIZE_CONFIG);
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Seed 脚本入口 — 可手动运行:`npx tsx src/lib/seed.ts`
*
* 运行时自动初始化已在 store.ts 的 ensureSeed() 中处理,
* 本脚本仅作为显式重置/查看用途。
*/
import { ensureSeed, getPosts, getCategories, getTags } from "./store";
async function main() {
await ensureSeed();
const [posts, categories, tags] = await Promise.all([
getPosts(),
getCategories(),
getTags(),
]);
console.log("Seed complete:", {
posts: posts.length,
categories: categories.length,
tags: tags.length,
});
process.exit(0);
}
main();
+379
View File
@@ -0,0 +1,379 @@
import { PrismaClient } from "@prisma/client";
import { sanitizeHtml } from "./sanitize";
import { seedPosts, seedCategories, seedTags } from "@/data/posts";
// ── Prisma 单例 ──
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// ── 类型定义(保持不变) ──
/** 统一的文章类型,前台与后台共用。 */
export interface Post {
id: string;
slug: string;
title: string;
excerpt: string;
content: string;
date: string;
category: string;
tags: string[];
/** 可选封面图,前台卡片可在有值时展示。 */
coverImage?: string;
readingTime: number;
featured: boolean;
status: "draft" | "published";
createdAt: string;
updatedAt: string;
}
export interface Category {
id: string;
name: string;
description: string;
}
export interface Tag {
id: string;
name: string;
}
/** 文章可见性:前台只展示已发布。 */
export type PublicPost = Omit<Post, "status"> & { status: "published" };
// ── 内部工具 ──
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/** 写入前对内容做净化,剥离任意可执行脚本。 */
function sanitizePostContent<T extends { content?: string }>(data: T): T {
if (typeof data.content === "string") {
return { ...data, content: sanitizeHtml(data.content) };
}
return data;
}
/** 将数据库行转为应用层 Post 类型(tags JSON → string[])。 */
function toPost(row: {
id: string;
slug: string;
title: string;
excerpt: string;
content: string;
date: string;
category: string;
tags: string;
coverImage: string | null;
readingTime: number;
featured: boolean;
status: string;
createdAt: string;
updatedAt: string;
}): Post {
return {
...row,
coverImage: row.coverImage ?? undefined,
tags: JSON.parse(row.tags) as string[],
status: row.status as Post["status"],
};
}
// ── Posts ──
/** 分页查询结果。 */
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/** 后台用:读取全部文章(含草稿)。 */
export async function getPosts(): Promise<Post[]> {
const rows = await prisma.post.findMany({ orderBy: { createdAt: "desc" } });
return rows.map(toPost);
}
/** 后台用:分页 + 搜索 + 排序查询。 */
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 } },
];
}
const [rows, total] = await Promise.all([
prisma.post.findMany({
where,
orderBy: { [sortBy]: sortDir },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.post.count({ where }),
]);
return {
data: rows.map(toPost),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/** 后台用:获取统计数据。 */
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 row = await prisma.post.create({
data: {
id: generateId(),
slug: sanitized.slug,
title: sanitized.title,
excerpt: sanitized.excerpt ?? "",
content: sanitized.content,
date: sanitized.date,
category: sanitized.category,
tags: JSON.stringify(sanitized.tags ?? []),
coverImage: sanitized.coverImage ?? null,
readingTime: sanitized.readingTime ?? 5,
featured: sanitized.featured ?? false,
status: sanitized.status ?? "draft",
createdAt: now,
updatedAt: now,
},
});
return toPost(row);
}
export async function updatePost(id: string, data: Partial<Post>): Promise<Post | null> {
const existing = await prisma.post.findUnique({ where: { id } });
if (!existing) return null;
// 禁止通过 update 覆盖不可变字段
const { id: _id, createdAt: _createdAt, ...rest } = data;
const sanitized = sanitizePostContent(rest);
const updateData: Record<string, unknown> = { updatedAt: new Date().toISOString() };
if (sanitized.slug !== undefined) updateData.slug = sanitized.slug;
if (sanitized.title !== undefined) updateData.title = sanitized.title;
if (sanitized.excerpt !== undefined) updateData.excerpt = sanitized.excerpt;
if (sanitized.content !== undefined) updateData.content = sanitized.content;
if (sanitized.date !== undefined) updateData.date = sanitized.date;
if (sanitized.category !== undefined) updateData.category = sanitized.category;
if (sanitized.tags !== undefined) updateData.tags = JSON.stringify(sanitized.tags);
if (sanitized.coverImage !== undefined) updateData.coverImage = sanitized.coverImage;
if (sanitized.readingTime !== undefined) updateData.readingTime = sanitized.readingTime;
if (sanitized.featured !== undefined) updateData.featured = sanitized.featured;
if (sanitized.status !== undefined) updateData.status = sanitized.status;
const row = await prisma.post.update({ where: { id }, data: updateData });
return toPost(row);
}
export async function deletePost(id: string): Promise<boolean> {
try {
await prisma.post.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// ── Categories ──
export async function getCategories(): Promise<Category[]> {
return prisma.category.findMany({ orderBy: { name: "asc" } });
}
export async function createCategory(data: Omit<Category, "id">): Promise<Category> {
return prisma.category.create({
data: { id: generateId(), ...data },
});
}
export async function updateCategory(
id: string,
data: Partial<Category>
): Promise<Category | null> {
const existing = await prisma.category.findUnique({ where: { id } });
if (!existing) return null;
const { id: _id, ...rest } = data;
return prisma.category.update({ where: { id }, data: rest });
}
export async function deleteCategory(id: string): Promise<boolean> {
try {
await prisma.category.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// ── Tags ──
export async function getTags(): Promise<Tag[]> {
return prisma.tag.findMany({ orderBy: { name: "asc" } });
}
export async function createTag(data: Omit<Tag, "id">): Promise<Tag> {
return prisma.tag.create({
data: { id: generateId(), ...data },
});
}
export async function deleteTag(id: string): Promise<boolean> {
try {
await prisma.tag.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// ── Auto seed ──
/**
* 模块加载时确保种子数据存在。幂等:仅在数据库为空时写入。
* 这样首次运行 / 部署后无需手动执行 seed 脚本。
*/
export async function ensureSeed(): Promise<void> {
const postCount = await prisma.post.count();
if (postCount === 0) {
for (const p of seedPosts) {
await createPost(p);
}
}
const catCount = await prisma.category.count();
if (catCount === 0) {
for (const c of seedCategories) {
await createCategory(c);
}
}
const tagCount = await prisma.tag.count();
if (tagCount === 0) {
for (const t of seedTags) {
await createTag({ name: t });
}
}
}
// 模块加载时自动 seed(异步,不阻塞导入)
ensureSeed().catch((err) => {
console.error("[store] ensureSeed failed:", err);
});
+26
View File
@@ -0,0 +1,26 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** 合并类名(不做 tailwind merge,保留简单拼接)。 */
export function cx(...inputs: (string | false | null | undefined)[]) {
return inputs.filter(Boolean).join(" ");
}
/** 将 ISO 日期格式化为中文友好的显示形式。 */
export function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
});
}
/** 阅读时间标签,如 "5 分钟阅读"。 */
export function readingTimeLabel(minutes: number): string {
return `${minutes} 分钟阅读`;
}
+31
View File
@@ -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),
});
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}