feat: 重构博客为水墨纸质风格 + 搭建后台管理系统
- 重新设计全站 UI:parchment/ink/terracotta 水墨纸质色系,宋式 serif 排版 - 新增页面:文章列表、文章详情、分类、标签、关于 - GSAP ScrollTrigger 滚动动画 + 逐字揭示效果 - 后台管理系统 /admin:文章/分类/标签 CRUD,JSON 文件存储 - 登录认证(cookie session) - 设计系统文档 UI.md
This commit is contained in:
@@ -30,6 +30,9 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# admin data storage
|
||||
src/data/storage/
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
|
||||
@@ -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`。层级关系:full(pill)> 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) → 0,0.7s
|
||||
- `animate-fade-in` — opacity 0 → 1,0.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.75rem,h3 为 1.35rem。引用块左侧 3px terracotta 色竖线。代码使用 `font-mono` + `bg-parchment-deep`。链接使用 terracotta 色 + 下划线,hover 时下划线加深。
|
||||
|
||||
### 全局细节
|
||||
|
||||
- 滚动条:6px 宽,track 为 `parchment-deep`,thumb 为 `warm-gray`,hover 变 `ink-muted`
|
||||
- 纸质纹理:`body::before` 叠加 SVG fractalNoise,opacity 0.03
|
||||
- 分隔线装饰:`.divider-ornament` 使用两端渐变线 + 中间符号
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"gsap": "^3.15.0",
|
||||
"next": "16.2.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
|
||||
Generated
+8
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
gsap:
|
||||
specifier: ^3.15.0
|
||||
version: 3.15.0
|
||||
next:
|
||||
specifier: 16.2.9
|
||||
version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -1221,6 +1224,9 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
gsap@3.15.0:
|
||||
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||
|
||||
has-bigints@1.1.0:
|
||||
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3269,6 +3275,8 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
gsap@3.15.0: {}
|
||||
|
||||
has-bigints@1.1.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import Link from "next/link";
|
||||
import GsapReveal from "@/components/GsapReveal";
|
||||
|
||||
export const metadata = {
|
||||
title: "关于",
|
||||
description: "关于胡旭和这个博客",
|
||||
};
|
||||
|
||||
const timeline = [
|
||||
{ year: "2026", title: "开始写博客", desc: "用 Next.js + Halo CMS 搭建个人博客,记录技术与生活" },
|
||||
{ year: "2025", title: "sui_lightbox 项目", desc: "开发3D灯箱模拟平台,探索标识行业的数字化可能" },
|
||||
{ year: "2024", title: "AI 图像生成", desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境" },
|
||||
{ year: "2023", title: "Web 3D 可视化", desc: "从 Three.js 到 React Three Fiber,进入数字孪生领域" },
|
||||
{ year: "2022", title: "前端开发", desc: "从后端转向全栈,React + TypeScript 成为主力技术栈" },
|
||||
];
|
||||
|
||||
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">胡旭</span>,一个来自安徽六安的前端开发者。
|
||||
</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="https://github.com/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>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="mailto:hi@asui.xyz"
|
||||
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>
|
||||
hi@asui.xyz
|
||||
</a>
|
||||
<a
|
||||
href="https://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", "Halo CMS", "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>,内容通过 <span className="text-terracotta">Halo CMS</span> 管理。字体使用了 Noto Serif SC(宋体)和 Cormorant Garamond 的组合,追求一种接近纸质杂志的阅读体验。
|
||||
</p>
|
||||
</div>
|
||||
</GsapReveal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Category } from "@/lib/store";
|
||||
|
||||
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("");
|
||||
|
||||
async function load() {
|
||||
const res = await fetch("/api/categories");
|
||||
setCategories(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
await fetch("/api/categories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
|
||||
});
|
||||
setNewName("");
|
||||
setNewDesc("");
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`确定删除分类「${name}」?`)) return;
|
||||
await fetch(`/api/categories?id=${id}`, { method: "DELETE" });
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleSave(id: string) {
|
||||
await fetch(`/api/categories?id=${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName }),
|
||||
});
|
||||
setEditingId(null);
|
||||
load();
|
||||
}
|
||||
|
||||
if (loading) return <div className="font-sans text-ink-muted">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-medium text-ink mb-8">分类管理</h1>
|
||||
|
||||
{/* Add form */}
|
||||
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="分类名称"
|
||||
className="flex-1 max-w-xs px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
/>
|
||||
<input
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
placeholder="描述(可选)"
|
||||
className="flex-1 max-w-sm px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
/>
|
||||
<button type="submit" className="px-5 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors shrink-0">
|
||||
添加
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* List */}
|
||||
<div className="space-y-2">
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.id} className="flex items-center justify-between p-4 rounded-xl bg-cream border border-warm-gray/10">
|
||||
{editingId === cat.id ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-warm-gray/20 bg-white font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={() => handleSave(cat.id)} className="font-sans text-xs text-sage hover:text-ink transition-colors">保存</button>
|
||||
<button onClick={() => setEditingId(null)} className="font-sans text-xs text-ink-muted hover:text-ink transition-colors">取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<span className="font-display text-base text-ink">{cat.name}</span>
|
||||
{cat.description && <span className="ml-3 font-sans text-sm text-ink-muted">{cat.description}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => { setEditingId(cat.id); setEditName(cat.name); }}
|
||||
className="font-sans text-xs text-ink-muted hover:text-terracotta transition-colors"
|
||||
>编辑</button>
|
||||
<button onClick={() => handleDelete(cat.id, cat.name)} className="font-sans text-xs text-ink-muted hover:text-red-600 transition-colors">删除</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{categories.length === 0 && (
|
||||
<div className="text-center py-16 font-sans text-ink-muted">暂无分类</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
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);
|
||||
|
||||
// Login page — render bare children without sidebar or auth check
|
||||
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);
|
||||
});
|
||||
}, [router, isLoginPage]);
|
||||
|
||||
if (isLoginPage) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (authed === null || !authed) {
|
||||
return <div className="min-h-screen bg-parchment flex items-center justify-center font-sans text-ink-muted">加载中...</div>;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth", { method: "DELETE" });
|
||||
router.push("/admin/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-parchment flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 shrink-0 border-r border-warm-gray/15 bg-cream/50 flex flex-col">
|
||||
<div className="p-5 border-b border-warm-gray/10">
|
||||
<Link href="/admin" className="font-display text-lg font-medium text-ink hover:text-terracotta transition-colors">
|
||||
随 · Admin
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.href === "/admin"
|
||||
? pathname === "/admin"
|
||||
: pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm transition-colors ${
|
||||
isActive ? "bg-terracotta/10 text-terracotta" : "text-ink-muted hover:text-ink hover:bg-warm-gray/10"
|
||||
}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={item.icon} />
|
||||
</svg>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="p-3 border-t border-warm-gray/10 space-y-1">
|
||||
<Link href="/" className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-ink-muted hover:text-ink hover:bg-warm-gray/10 transition-colors">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
查看前台
|
||||
</Link>
|
||||
<button onClick={handleLogout} className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-ink-muted hover:text-red-600 hover:bg-red-50 transition-colors text-left">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-8 max-w-5xl">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await fetch("/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
router.push("/admin");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "登录失败");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-parchment flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="font-display text-3xl font-medium text-ink">后台管理</h1>
|
||||
<p className="mt-2 font-sans text-sm text-ink-muted">asui.xyz</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="输入管理密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="font-sans text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 rounded-xl bg-ink text-cream font-sans text-sm tracking-wide hover:bg-terracotta transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "验证中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Post, Category, Tag } from "@/lib/store";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch("/api/posts").then((r) => r.json()),
|
||||
fetch("/api/categories").then((r) => r.json()),
|
||||
fetch("/api/tags").then((r) => r.json()),
|
||||
]).then(([p, c, t]) => {
|
||||
setPosts(p);
|
||||
setCategories(c);
|
||||
setTags(t);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="font-sans text-ink-muted">加载中...</div>;
|
||||
}
|
||||
|
||||
const published = posts.filter((p) => p.status === "published").length;
|
||||
const drafts = posts.filter((p) => p.status === "draft").length;
|
||||
const featured = posts.filter((p) => p.featured).length;
|
||||
|
||||
const stats = [
|
||||
{ label: "文章总数", value: posts.length, color: "text-ink" },
|
||||
{ label: "已发布", value: published, color: "text-sage" },
|
||||
{ label: "草稿", value: drafts, color: "text-terracotta" },
|
||||
{ label: "精选", value: featured, color: "text-terracotta" },
|
||||
{ label: "分类", value: categories.length, color: "text-ink" },
|
||||
{ label: "标签", value: tags.length, color: "text-ink" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="font-display text-3xl font-medium text-ink">仪表盘</h1>
|
||||
<Link href="/admin/posts/new" className="px-4 py-2 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">
|
||||
+ 新文章
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-10">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="p-4 rounded-xl bg-cream border border-warm-gray/10">
|
||||
<div className={`font-display text-2xl font-medium ${s.color}`}>{s.value}</div>
|
||||
<div className="font-sans text-xs text-ink-muted mt-1">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent posts */}
|
||||
<h2 className="font-display text-xl font-medium text-ink mb-4">最近文章</h2>
|
||||
<div className="space-y-2">
|
||||
{posts.slice(0, 5).map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/admin/posts/${post.id}`}
|
||||
className="flex items-center justify-between p-4 rounded-xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-display text-base text-ink truncate">{post.title}</div>
|
||||
<div className="font-sans text-xs text-ink-muted mt-0.5">{post.category} · {post.date}</div>
|
||||
</div>
|
||||
<span className={`ml-4 shrink-0 font-sans text-xs px-2 py-0.5 rounded-full ${
|
||||
post.status === "published" ? "bg-sage/10 text-sage" : "bg-terracotta/10 text-terracotta"
|
||||
}`}>
|
||||
{post.status === "published" ? "已发布" : "草稿"}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import type { Post, Category, Tag } from "@/lib/store";
|
||||
|
||||
export default function EditPostPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: "", slug: "", excerpt: "", content: "",
|
||||
category: "", tags: [] as string[], readingTime: 5,
|
||||
featured: false, status: "draft" as "draft" | "published",
|
||||
date: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`/api/posts/${id}`).then((r) => r.json()),
|
||||
fetch("/api/categories").then((r) => r.json()),
|
||||
fetch("/api/tags").then((r) => r.json()),
|
||||
]).then(([p, c, t]) => {
|
||||
setPost(p);
|
||||
setCategories(c);
|
||||
setAllTags(t);
|
||||
setForm({
|
||||
title: p.title, slug: p.slug, excerpt: p.excerpt,
|
||||
content: p.content, category: p.category, tags: p.tags,
|
||||
readingTime: p.readingTime, featured: p.featured,
|
||||
status: p.status, date: p.date,
|
||||
});
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch(`/api/posts/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
if (res.ok) router.push("/admin/posts");
|
||||
}
|
||||
|
||||
function toggleTag(tagName: string) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
tags: prev.tags.includes(tagName)
|
||||
? prev.tags.filter((t) => t !== tagName)
|
||||
: [...prev.tags, tagName],
|
||||
}));
|
||||
}
|
||||
|
||||
if (loading) return <div className="font-sans text-ink-muted">加载中...</div>;
|
||||
if (!post) return <div className="font-sans text-red-600">文章未找到</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button onClick={() => router.back()} className="font-sans text-sm text-ink-muted hover:text-ink transition-colors">← 返回</button>
|
||||
<h1 className="font-display text-3xl font-medium text-ink">编辑文章</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">标题</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-display text-lg text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">Slug</label>
|
||||
<input
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm({ ...form, slug: e.target.value })}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">摘要</label>
|
||||
<textarea
|
||||
value={form.excerpt}
|
||||
onChange={(e) => setForm({ ...form, excerpt: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">内容(HTML)</label>
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
rows={12}
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors resize-y font-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">分类</label>
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
required
|
||||
>
|
||||
<option value="">选择分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">阅读时间(分钟)</label>
|
||||
<input
|
||||
type="number" min={1}
|
||||
value={form.readingTime}
|
||||
onChange={(e) => setForm({ ...form, readingTime: Number(e.target.value) })}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">标签</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => (
|
||||
<button key={tag.id} type="button" onClick={() => toggleTag(tag.name)}
|
||||
className={`font-sans text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
form.tags.includes(tag.name)
|
||||
? "bg-terracotta/10 border-terracotta/30 text-terracotta"
|
||||
: "border-warm-gray/20 text-ink-muted hover:border-terracotta/20"
|
||||
}`}
|
||||
>{tag.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
|
||||
<input type="checkbox" checked={form.featured} onChange={(e) => setForm({ ...form, featured: e.target.checked })} className="accent-terracotta" />
|
||||
精选文章
|
||||
</label>
|
||||
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
|
||||
<input type="radio" name="status" checked={form.status === "draft"} onChange={() => setForm({ ...form, status: "draft" })} className="accent-terracotta" />
|
||||
草稿
|
||||
</label>
|
||||
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
|
||||
<input type="radio" name="status" checked={form.status === "published"} onChange={() => setForm({ ...form, status: "published" })} className="accent-terracotta" />
|
||||
发布
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="submit" className="px-6 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">保存修改</button>
|
||||
<button type="button" onClick={() => router.back()} className="px-6 py-2.5 rounded-lg border border-warm-gray/20 font-sans text-sm text-ink-muted hover:text-ink transition-colors">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Category, Tag } from "@/lib/store";
|
||||
|
||||
export default function NewPostPage() {
|
||||
const router = useRouter();
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
slug: "",
|
||||
excerpt: "",
|
||||
content: "",
|
||||
category: "",
|
||||
tags: [] as string[],
|
||||
readingTime: 5,
|
||||
featured: false,
|
||||
status: "draft" as "draft" | "published",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/categories").then((r) => r.json()).then(setCategories);
|
||||
fetch("/api/tags").then((r) => r.json()).then(setAllTags);
|
||||
}, []);
|
||||
|
||||
function autoSlug(title: string) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u4e00-\u9fff]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const slug = form.slug || autoSlug(form.title);
|
||||
const res = await fetch("/api/posts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form, slug }),
|
||||
});
|
||||
if (res.ok) router.push("/admin/posts");
|
||||
}
|
||||
|
||||
function toggleTag(tagName: string) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
tags: prev.tags.includes(tagName)
|
||||
? prev.tags.filter((t) => t !== tagName)
|
||||
: [...prev.tags, tagName],
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button onClick={() => router.back()} className="font-sans text-sm text-ink-muted hover:text-ink transition-colors">
|
||||
← 返回
|
||||
</button>
|
||||
<h1 className="font-display text-3xl font-medium text-ink">新文章</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">标题</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-display text-lg text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
placeholder="文章标题"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug + Date */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">Slug(留空自动生成)</label>
|
||||
<input
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm({ ...form, slug: e.target.value })}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
placeholder="my-post-slug"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">摘要</label>
|
||||
<textarea
|
||||
value={form.excerpt}
|
||||
onChange={(e) => setForm({ ...form, excerpt: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors resize-none"
|
||||
placeholder="文章摘要..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">内容(HTML)</label>
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
rows={12}
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors resize-y font-mono"
|
||||
placeholder="<p>文章内容...</p>"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category + Reading Time */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">分类</label>
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
required
|
||||
>
|
||||
<option value="">选择分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">阅读时间(分钟)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.readingTime}
|
||||
onChange={(e) => setForm({ ...form, readingTime: Number(e.target.value) })}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">标签</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
className={`font-sans text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
form.tags.includes(tag.name)
|
||||
? "bg-terracotta/10 border-terracotta/30 text-terracotta"
|
||||
: "border-warm-gray/20 text-ink-muted hover:border-terracotta/20"
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status + Featured */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.featured}
|
||||
onChange={(e) => setForm({ ...form, featured: e.target.checked })}
|
||||
className="accent-terracotta"
|
||||
/>
|
||||
精选文章
|
||||
</label>
|
||||
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
checked={form.status === "draft"}
|
||||
onChange={() => setForm({ ...form, status: "draft" })}
|
||||
className="accent-terracotta"
|
||||
/>
|
||||
草稿
|
||||
</label>
|
||||
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
checked={form.status === "published"}
|
||||
onChange={() => setForm({ ...form, status: "published" })}
|
||||
className="accent-terracotta"
|
||||
/>
|
||||
发布
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="submit" className="px-6 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">
|
||||
保存
|
||||
</button>
|
||||
<button type="button" onClick={() => router.back()} className="px-6 py-2.5 rounded-lg border border-warm-gray/20 font-sans text-sm text-ink-muted hover:text-ink transition-colors">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Post } from "@/lib/store";
|
||||
|
||||
export default function PostsPage() {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
|
||||
|
||||
async function loadPosts() {
|
||||
const res = await fetch("/api/posts");
|
||||
const data = await res.json();
|
||||
setPosts(data);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { loadPosts(); }, []);
|
||||
|
||||
async function handleDelete(id: string, title: string) {
|
||||
if (!confirm(`确定删除「${title}」?`)) return;
|
||||
await fetch(`/api/posts?id=${id}`, { method: "DELETE" });
|
||||
loadPosts();
|
||||
}
|
||||
|
||||
const filtered = filter === "all" ? posts : posts.filter((p) => p.status === filter);
|
||||
|
||||
if (loading) return <div className="font-sans text-ink-muted">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="font-display text-3xl font-medium text-ink">文章管理</h1>
|
||||
<Link href="/admin/posts/new" className="px-4 py-2 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">
|
||||
+ 新文章
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-4 mb-6 font-sans text-sm">
|
||||
{(["all", "published", "draft"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`pb-1 border-b-2 transition-colors ${
|
||||
filter === f ? "border-terracotta text-ink" : "border-transparent text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "全部" : f === "published" ? "已发布" : "草稿"}
|
||||
<span className="ml-1 text-xs text-ink-muted">({f === "all" ? posts.length : posts.filter((p) => p.status === f).length})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Posts list */}
|
||||
<div className="space-y-3">
|
||||
{filtered.map((post) => (
|
||||
<div key={post.id} className="p-5 rounded-xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href={`/admin/posts/${post.id}`} className="font-display text-lg text-ink hover:text-terracotta transition-colors">
|
||||
{post.title}
|
||||
</Link>
|
||||
<p className="font-sans text-sm text-ink-muted mt-1 line-clamp-1">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-3 mt-2 font-sans text-xs text-ink-muted">
|
||||
<span>{post.category}</span>
|
||||
<span>·</span>
|
||||
<span>{post.date}</span>
|
||||
<span>·</span>
|
||||
<span>{post.readingTime} 分钟</span>
|
||||
{post.featured && <span className="text-terracotta">精选</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`font-sans text-xs px-2 py-0.5 rounded-full ${
|
||||
post.status === "published" ? "bg-sage/10 text-sage" : "bg-terracotta/10 text-terracotta"
|
||||
}`}>
|
||||
{post.status === "published" ? "已发布" : "草稿"}
|
||||
</span>
|
||||
<Link href={`/admin/posts/${post.id}`} className="font-sans text-xs text-ink-muted hover:text-terracotta transition-colors px-2">
|
||||
编辑
|
||||
</Link>
|
||||
<button onClick={() => handleDelete(post.id, post.title)} className="font-sans text-xs text-ink-muted hover:text-red-600 transition-colors px-2">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-16 font-sans text-ink-muted">暂无文章</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Tag } from "@/lib/store";
|
||||
|
||||
export default function TagsPage() {
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
async function load() {
|
||||
const res = await fetch("/api/tags");
|
||||
setTags(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
await fetch("/api/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
});
|
||||
setNewName("");
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`确定删除标签「${name}」?`)) return;
|
||||
await fetch(`/api/tags?id=${id}`, { method: "DELETE" });
|
||||
load();
|
||||
}
|
||||
|
||||
if (loading) return <div className="font-sans text-ink-muted">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-medium text-ink mb-8">标签管理</h1>
|
||||
|
||||
{/* Add form */}
|
||||
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="标签名称"
|
||||
className="flex-1 max-w-xs px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
|
||||
/>
|
||||
<button type="submit" className="px-5 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors shrink-0">
|
||||
添加
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Tag cloud */}
|
||||
<div className="p-6 rounded-xl bg-cream border border-warm-gray/10">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag.id} className="group inline-flex items-center gap-1.5 font-sans text-sm px-3 py-1.5 rounded-full border border-warm-gray/15 text-ink hover:border-terracotta/30 transition-colors">
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => handleDelete(tag.id, tag.name)}
|
||||
className="opacity-0 group-hover:opacity-100 text-ink-muted hover:text-red-600 transition-all text-xs leading-none"
|
||||
>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{tags.length === 0 && (
|
||||
<div className="text-center py-8 font-sans text-ink-muted">暂无标签</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const ADMIN_PASSWORD = "asui2026"; // 后续可改环境变量
|
||||
const SESSION_KEY = "admin_session";
|
||||
const SESSION_VALUE = "authenticated";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
||||
if (body.password === ADMIN_PASSWORD) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_KEY, SESSION_VALUE, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
path: "/",
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "密码错误" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_KEY);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies();
|
||||
const session = cookieStore.get(SESSION_KEY);
|
||||
return NextResponse.json({ authenticated: session?.value === SESSION_VALUE });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getCategories, createCategory, updateCategory, deleteCategory } from "@/lib/store";
|
||||
|
||||
async function checkAuth(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get("admin_session")?.value === "authenticated";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json(getCategories());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json();
|
||||
const cat = createCategory(body);
|
||||
return NextResponse.json(cat, { status: 201 });
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||
const body = await request.json();
|
||||
const cat = updateCategory(id, body);
|
||||
if (!cat) return NextResponse.json({ error: "未找到" }, { status: 404 });
|
||||
return NextResponse.json(cat);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||
const ok = deleteCategory(id);
|
||||
return NextResponse.json({ ok });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getPost, updatePost } from "@/lib/store";
|
||||
|
||||
async function checkAuth(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get("admin_session")?.value === "authenticated";
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const post = 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 }> }
|
||||
) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const post = updatePost(id, body);
|
||||
if (!post) return NextResponse.json({ error: "未找到" }, { status: 404 });
|
||||
return NextResponse.json(post);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getPosts, createPost, deletePost } from "@/lib/store";
|
||||
|
||||
async function checkAuth(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get("admin_session")?.value === "authenticated";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const posts = getPosts();
|
||||
return NextResponse.json(posts);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json();
|
||||
const post = createPost(body);
|
||||
return NextResponse.json(post, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||
const ok = deletePost(id);
|
||||
return NextResponse.json({ ok });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getTags, createTag, deleteTag } from "@/lib/store";
|
||||
|
||||
async function checkAuth(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get("admin_session")?.value === "authenticated";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json(getTags());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json();
|
||||
const tag = createTag(body);
|
||||
return NextResponse.json(tag, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||
const ok = deleteTag(id);
|
||||
return NextResponse.json({ ok });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { posts } from "@/data/posts";
|
||||
import BlogList from "@/components/BlogList";
|
||||
|
||||
export const metadata = {
|
||||
title: "文章",
|
||||
description: "胡旭的博客文章 — 技术、随笔、旅行、阅读",
|
||||
};
|
||||
|
||||
export default function BlogPage() {
|
||||
return <BlogList posts={posts} />;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Link from "next/link";
|
||||
import { categories, posts } from "@/data/posts";
|
||||
import GsapReveal from "@/components/GsapReveal";
|
||||
|
||||
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 function CategoriesPage() {
|
||||
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>
|
||||
|
||||
<GsapReveal variant="fade-up" stagger={0.12} className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-20">
|
||||
{categories.map((cat) => {
|
||||
const icon = categoryIcons[cat.name] || categoryIcons["随笔"];
|
||||
const catPosts = posts.filter((p) => p.category === cat.name);
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className="group relative p-7 rounded-2xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 hover:shadow-lg hover:shadow-terracotta/5 transition-all duration-500"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-parchment-deep flex items-center justify-center mb-5 group-hover:bg-terracotta/10 transition-colors duration-300">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-ink-muted group-hover:text-terracotta transition-colors duration-300">
|
||||
<path d={icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 mb-2">
|
||||
<h2 className="font-display text-2xl font-medium text-ink">{cat.name}</h2>
|
||||
<span className="font-sans text-xs text-ink-muted">{cat.count} 篇</span>
|
||||
</div>
|
||||
<p className="font-body text-sm text-ink-muted leading-relaxed mb-5">
|
||||
{cat.description}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{catPosts.slice(0, 3).map((post) => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+197
-17
@@ -1,26 +1,206 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400;1,500&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&family=Source+Serif+4:ital,wght@0,300;0,400;0,500;0,600;1,400&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
/* ── Design Tokens ── */
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
/* 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 */
|
||||
--font-display: "Noto Serif SC", "Cormorant Garamond", "Source Han Serif SC", "Songti SC", serif;
|
||||
--font-body: "Noto Serif SC", "Source Serif 4", "Source Han Serif SC", "Songti SC", serif;
|
||||
--font-sans: "Noto Sans SC", "DM Sans", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
|
||||
/* Spacing scale */
|
||||
--spacing-page: clamp(1.5rem, 5vw, 6rem);
|
||||
|
||||
/* Transitions */
|
||||
--ease-literary: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* ── Base Styles ── */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
|
||||
+14
-18
@@ -1,20 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "随 · asui.xyz",
|
||||
template: "%s | 随",
|
||||
},
|
||||
description: "胡旭的个人博客 — 记录技术探索、生活感悟与创业路上的点滴",
|
||||
keywords: ["博客", "技术", "生活", "创业", "Web开发", "AI"],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +18,12 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="zh-CN" className="h-full">
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-61
@@ -1,65 +1,16 @@
|
||||
import Image from "next/image";
|
||||
import { posts } from "@/data/posts";
|
||||
import HeroSection from "@/components/HeroSection";
|
||||
import { FeaturedGrid, RecentList } from "@/components/PostSections";
|
||||
|
||||
export default function HomePage() {
|
||||
const featuredPosts = posts.filter((p) => p.featured);
|
||||
const recentPosts = posts.slice(0, 5);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<>
|
||||
<HeroSection />
|
||||
<FeaturedGrid posts={featuredPosts} />
|
||||
<RecentList posts={recentPosts} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { posts } from "@/data/posts";
|
||||
import PostContent from "@/components/PostContent";
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return posts.map((post) => ({ slug: post.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const post = posts.find((p) => p.slug === slug);
|
||||
if (!post) return {};
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const post = posts.find((p) => p.slug === slug);
|
||||
if (!post) notFound();
|
||||
|
||||
const currentIndex = posts.findIndex((p) => p.slug === slug);
|
||||
const prevPost = currentIndex > 0 ? posts[currentIndex - 1] : null;
|
||||
const nextPost = currentIndex < posts.length - 1 ? posts[currentIndex + 1] : null;
|
||||
|
||||
return <PostContent post={post} prevPost={prevPost} nextPost={nextPost} />;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Link from "next/link";
|
||||
import { allTags } from "@/data/posts";
|
||||
import GsapReveal from "@/components/GsapReveal";
|
||||
|
||||
export const metadata = {
|
||||
title: "标签",
|
||||
description: "按标签浏览博客文章",
|
||||
};
|
||||
|
||||
export default function TagsPage() {
|
||||
const maxCount = Math.max(...allTags.map((t) => t.count));
|
||||
|
||||
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) {
|
||||
const ratio = count / maxCount;
|
||||
return ratio >= 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>
|
||||
|
||||
<GsapReveal variant="scale" stagger={0.04} className="max-w-3xl">
|
||||
<div className="flex flex-wrap gap-3 md:gap-4 items-center">
|
||||
{allTags.map((tag) => (
|
||||
<Link
|
||||
key={tag.name}
|
||||
href="/blog"
|
||||
className="group inline-flex items-center gap-1.5 px-4 py-2 rounded-full border border-warm-gray/15 bg-cream hover:border-terracotta hover:bg-terracotta/5 transition-all duration-300"
|
||||
>
|
||||
<span className={`font-display ${getTagSize(tag.count)} ${getTagWeight(tag.count)} group-hover:text-terracotta transition-colors duration-300`}>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="font-sans text-xs text-ink-muted group-hover:text-terracotta transition-colors duration-300">
|
||||
{tag.count}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</GsapReveal>
|
||||
|
||||
<GsapReveal variant="fade-in" className="mt-20">
|
||||
<div className="divider-ornament">
|
||||
<span className="font-display text-sm italic whitespace-nowrap">
|
||||
标签是文章的索引
|
||||
</span>
|
||||
</div>
|
||||
</GsapReveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import type { Post } from "@/data/posts";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
export default function BlogList({ posts }: { posts: Post[] }) {
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ctxs: gsap.Context[] = [];
|
||||
|
||||
// Header animation
|
||||
if (headerRef.current) {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".blog-header-el", {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.7,
|
||||
stagger: 0.1,
|
||||
ease: "power3.out",
|
||||
});
|
||||
}, headerRef.current);
|
||||
ctxs.push(ctx);
|
||||
}
|
||||
|
||||
// List items stagger on scroll
|
||||
if (listRef.current) {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".blog-list-item", {
|
||||
y: 40,
|
||||
opacity: 0,
|
||||
duration: 0.7,
|
||||
stagger: 0.1,
|
||||
ease: "power3.out",
|
||||
scrollTrigger: {
|
||||
trigger: listRef.current,
|
||||
start: "top 88%",
|
||||
},
|
||||
});
|
||||
}, listRef.current);
|
||||
ctxs.push(ctx);
|
||||
}
|
||||
|
||||
return () => ctxs.forEach((c) => c.revert());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
|
||||
{/* Header */}
|
||||
<div ref={headerRef} className="mb-14">
|
||||
<h1 className="blog-header-el font-display text-4xl md:text-5xl font-light text-ink tracking-tight">
|
||||
文章
|
||||
</h1>
|
||||
<p className="blog-header-el mt-3 font-body text-ink-muted max-w-md">
|
||||
所有的文字,按时间排列。你也可以通过{" "}
|
||||
<Link href="/categories" className="text-terracotta hover:underline underline-offset-2">
|
||||
分类
|
||||
</Link>{" "}
|
||||
或{" "}
|
||||
<Link href="/tags" className="text-terracotta hover:underline underline-offset-2">
|
||||
标签
|
||||
</Link>{" "}
|
||||
来探索。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Post list */}
|
||||
<div ref={listRef} className="space-y-0">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/posts/${post.slug}`}
|
||||
className="blog-list-item group block"
|
||||
>
|
||||
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-6 px-6 rounded-xl transition-all duration-400">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-8">
|
||||
<div className="shrink-0 md:w-36 md:pt-1">
|
||||
<time className="font-sans text-sm text-ink-muted tabular-nums">
|
||||
{formatDate(post.date)}
|
||||
</time>
|
||||
<div className="mt-1 flex items-center gap-2 md:flex-col md:items-start md:gap-1">
|
||||
<span className="font-sans text-sm text-terracotta">
|
||||
{post.category}
|
||||
</span>
|
||||
<span className="hidden md:block font-sans text-sm text-ink-muted">
|
||||
{post.readingTime} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-display text-xl md:text-2xl font-medium text-ink group-hover:text-terracotta transition-colors duration-300 leading-snug">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="mt-2 font-body text-base text-ink-muted leading-relaxed line-clamp-2 max-w-xl">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{post.tags.slice(0, 4).map((tag) => (
|
||||
<span key={tag} className="font-sans text-xs px-2.5 py-0.5 rounded-full bg-parchment-deep text-ink-muted">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:flex shrink-0 items-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 pt-2">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-terracotta">
|
||||
<path d="M7 12h10M13 8l4 4-4 4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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="https://github.com/huxu" target="_blank" rel="noopener noreferrer" className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300">
|
||||
GitHub
|
||||
</a>
|
||||
<a href="mailto:hi@asui.xyz" 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">
|
||||
© {new Date().getFullYear()} 胡旭. All rights reserved.
|
||||
</p>
|
||||
<p className="font-sans text-xs text-ink-muted">
|
||||
Powered by <span className="text-terracotta">Next.js</span> & <span className="text-terracotta">Halo</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const el = ref.current;
|
||||
const children = el.children.length > 1 ? el.children : [el];
|
||||
|
||||
const variants = {
|
||||
"fade-up": { y: 40, opacity: 0 },
|
||||
"fade-in": { opacity: 0 },
|
||||
"slide-left": { x: -40, opacity: 0 },
|
||||
"slide-right": { x: 40, opacity: 0 },
|
||||
scale: { scale: 0.92, opacity: 0 },
|
||||
};
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
if (stagger > 0 && children.length > 1) {
|
||||
// Each child gets its own ScrollTrigger so off-screen items animate when scrolled into view
|
||||
Array.from(children).forEach((child, i) => {
|
||||
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(children, {
|
||||
...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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"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">
|
||||
{/* 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}
|
||||
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="Toggle 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 */}
|
||||
{menuOpen && (
|
||||
<div className="md:hidden pb-4 animate-fade-in">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== "/" && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={`
|
||||
block py-3 px-2 font-sans text-sm tracking-wide border-b border-warm-gray/10 transition-colors duration-300
|
||||
${isActive ? "text-terracotta" : "text-ink-muted"}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
|
||||
export default function HeroSection() {
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionRef.current) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({ delay: 0.2 });
|
||||
|
||||
// Subtitle fade in
|
||||
tl.from(".hero-subtitle", {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
});
|
||||
|
||||
// Heading — split chars and stagger
|
||||
if (headingRef.current) {
|
||||
const spans = headingRef.current.querySelectorAll(".hero-char");
|
||||
tl.from(
|
||||
spans,
|
||||
{
|
||||
y: 40,
|
||||
opacity: 0,
|
||||
filter: "blur(6px)",
|
||||
duration: 0.6,
|
||||
stagger: 0.035,
|
||||
ease: "power3.out",
|
||||
},
|
||||
"-=0.3"
|
||||
);
|
||||
}
|
||||
|
||||
// Description
|
||||
tl.from(
|
||||
".hero-desc",
|
||||
{ y: 20, opacity: 0, duration: 0.6, ease: "power3.out" },
|
||||
"-=0.3"
|
||||
);
|
||||
|
||||
// Buttons
|
||||
tl.from(
|
||||
".hero-btn",
|
||||
{ y: 15, opacity: 0, duration: 0.5, stagger: 0.1, ease: "power3.out" },
|
||||
"-=0.3"
|
||||
);
|
||||
|
||||
// Decorative line
|
||||
tl.from(".hero-divider", {
|
||||
scaleX: 0,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: "power2.inOut",
|
||||
});
|
||||
}, sectionRef.current);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// Split heading into char spans
|
||||
const headingChars = (text: string, className?: string) =>
|
||||
[...text].map((char, i) => (
|
||||
<span key={i} className="hero-char inline-block" style={char === " " ? { width: "0.3em" } : undefined}>
|
||||
{char}
|
||||
</span>
|
||||
));
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} 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">
|
||||
胡旭的个人博客
|
||||
</p>
|
||||
<h1
|
||||
ref={headingRef}
|
||||
className="font-display text-4xl md:text-6xl font-light text-ink leading-tight tracking-tight"
|
||||
>
|
||||
{headingChars("写字,")}
|
||||
<br />
|
||||
<span className="text-terracotta">{headingChars("是一种思考的方式")}</span>
|
||||
</h1>
|
||||
<p className="hero-desc mt-6 font-body text-lg text-ink-muted leading-relaxed max-w-lg">
|
||||
这里记录着技术探索中的发现、旅途中的风景、阅读时的感悟,以及一个小镇青年创业路上的点点滴滴。
|
||||
</p>
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
<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>
|
||||
|
||||
{/* Decorative line */}
|
||||
<div className="hero-divider mt-20 flex items-center gap-4 text-warm-gray origin-center">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-warm-gray/20 to-transparent" />
|
||||
<span className="font-display text-sm italic">精选文章</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-l from-warm-gray/20 to-transparent" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import type { Post } from "@/data/posts";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
export default function PostContent({
|
||||
post,
|
||||
prevPost,
|
||||
nextPost,
|
||||
}: {
|
||||
post: Post;
|
||||
prevPost: Post | null;
|
||||
nextPost: Post | null;
|
||||
}) {
|
||||
const articleRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!articleRef.current) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline();
|
||||
|
||||
// Back link
|
||||
tl.from(".post-back", { x: -20, opacity: 0, duration: 0.5, ease: "power3.out" });
|
||||
|
||||
// Category badge
|
||||
tl.from(".post-category", { y: 15, opacity: 0, duration: 0.5, ease: "power3.out" }, "-=0.2");
|
||||
|
||||
// Title — char by char reveal
|
||||
const titleChars = document.querySelectorAll(".post-title-char");
|
||||
tl.from(
|
||||
titleChars,
|
||||
{ y: 30, opacity: 0, filter: "blur(4px)", duration: 0.5, stagger: 0.03, ease: "power3.out" },
|
||||
"-=0.3"
|
||||
);
|
||||
|
||||
// Meta
|
||||
tl.from(".post-meta", { y: 10, opacity: 0, duration: 0.4, ease: "power3.out" }, "-=0.2");
|
||||
|
||||
// Divider — draw from center
|
||||
tl.from(".post-divider-line", { scaleX: 0, duration: 0.6, ease: "power2.inOut", stagger: 0.1 }, "-=0.2");
|
||||
tl.from(".post-divider-dot", { scale: 0, duration: 0.3, ease: "back.out(2)" }, "-=0.3");
|
||||
|
||||
// Content paragraphs stagger on scroll
|
||||
const paragraphs = articleRef.current!.querySelectorAll(".prose-literary > *");
|
||||
paragraphs.forEach((p) => {
|
||||
gsap.from(p, {
|
||||
y: 25,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
scrollTrigger: {
|
||||
trigger: p,
|
||||
start: "top 90%",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Tags
|
||||
gsap.from(".post-tag", {
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
duration: 0.4,
|
||||
stagger: 0.05,
|
||||
ease: "back.out(1.5)",
|
||||
scrollTrigger: {
|
||||
trigger: ".post-tags",
|
||||
start: "top 90%",
|
||||
},
|
||||
});
|
||||
|
||||
// Prev/Next
|
||||
gsap.from(".post-nav", {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.1,
|
||||
ease: "power3.out",
|
||||
scrollTrigger: {
|
||||
trigger: ".post-navs",
|
||||
start: "top 90%",
|
||||
},
|
||||
});
|
||||
}, articleRef.current);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// Split title into char spans
|
||||
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={articleRef} 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>{post.readingTime} min read</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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 */}
|
||||
<div
|
||||
className="max-w-2xl mx-auto prose-literary"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="post-tags max-w-2xl mx-auto mt-14 pt-8 border-t border-warm-gray/10">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
href="/tags"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import type { Post } from "@/data/posts";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
function FeaturedCard({ post }: { post: Post }) {
|
||||
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>{post.readingTime} min read</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: Post[] }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".featured-card", {
|
||||
y: 50,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
stagger: 0.15,
|
||||
ease: "power3.out",
|
||||
scrollTrigger: {
|
||||
trigger: ref.current,
|
||||
start: "top 85%",
|
||||
},
|
||||
});
|
||||
}, ref.current);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={ref} 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: Post[] }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".recent-item", {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
stagger: 0.08,
|
||||
ease: "power3.out",
|
||||
scrollTrigger: {
|
||||
trigger: ref.current,
|
||||
start: "top 85%",
|
||||
},
|
||||
});
|
||||
}, ref.current);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={ref} 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
export interface Post {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
date: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
coverImage?: string;
|
||||
readingTime: number;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export const posts: Post[] = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
slug: "notes-on-digital-twin",
|
||||
title: "数字孪生笔记:从3D建模到Web可视化",
|
||||
excerpt: "从 Three.js 到 React Three Fiber,Web 3D 的门槛比想象中低很多,但要做好,需要理解的东西远不止代码。",
|
||||
content: `<p>从 Three.js 到 React Three Fiber,Web 3D 的门槛比想象中低很多。</p><p>但要做好,需要理解的东西远不止代码。光照、材质、相机、性能优化,每一个都是深坑。这篇文章记录我在数字孪生项目中的一些实践和思考。</p>`,
|
||||
date: "2026-06-05",
|
||||
category: "技术",
|
||||
tags: ["Web3D", "React", "Three.js", "前端"],
|
||||
readingTime: 8,
|
||||
},
|
||||
{
|
||||
slug: "reading-list-spring",
|
||||
title: "春日书单:五本改变我看世界方式的书",
|
||||
excerpt: "春天适合读一些柔软的书。不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。",
|
||||
content: `<p>春天适合读一些柔软的书。</p><p>不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。以下五本书,在这个春天给了我很多安静的力量。</p>`,
|
||||
date: "2026-05-28",
|
||||
category: "阅读",
|
||||
tags: ["阅读", "书单", "生活"],
|
||||
readingTime: 5,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
slug: "rainy-day-thoughts",
|
||||
title: "雨天杂记",
|
||||
excerpt: "六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。泡一壶六安瓜片,坐在窗前看雨,什么都不想。",
|
||||
content: `<p>六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。</p><p>泡一壶六安瓜片,坐在窗前看雨,什么都不想。这种无所事事的下午,反而是一周中最有创造力的时刻。</p>`,
|
||||
date: "2026-05-05",
|
||||
category: "随笔",
|
||||
tags: ["随笔", "生活", "六安"],
|
||||
readingTime: 3,
|
||||
},
|
||||
{
|
||||
slug: "next-js-blog-from-scratch",
|
||||
title: "从零搭建一个博客系统",
|
||||
excerpt: "为什么选择 Next.js + Halo CMS?为什么不用 WordPress?这篇文章聊聊技术选型的思考过程。",
|
||||
content: `<p>为什么选择 Next.js + Halo CMS?为什么不用 WordPress?</p><p>作为一个前端开发者,我希望能完全掌控博客的UI和交互体验。Halo CMS 提供了足够好的内容管理API,而 Next.js 则让我可以自由设计前端展示。</p>`,
|
||||
date: "2026-04-28",
|
||||
category: "技术",
|
||||
tags: ["Next.js", "博客", "Halo CMS", "前端"],
|
||||
readingTime: 6,
|
||||
},
|
||||
];
|
||||
|
||||
export const categories = [
|
||||
{ name: "技术", count: 3, description: "代码、架构与技术探索" },
|
||||
{ name: "随笔", count: 2, description: "生活感悟与碎片思考" },
|
||||
{ name: "旅行", count: 1, description: "在路上看到的风景与人" },
|
||||
{ name: "阅读", count: 1, description: "书中世界与阅读心得" },
|
||||
{ name: "创业", count: 1, description: "产品思考与创业记录" },
|
||||
];
|
||||
|
||||
export const allTags = [
|
||||
{ name: "写作", count: 1 },
|
||||
{ name: "思考", count: 1 },
|
||||
{ name: "生活哲学", count: 1 },
|
||||
{ name: "旅行", count: 1 },
|
||||
{ name: "自然", count: 1 },
|
||||
{ name: "六安", count: 2 },
|
||||
{ name: "Web3D", count: 1 },
|
||||
{ name: "React", count: 1 },
|
||||
{ name: "Three.js", count: 1 },
|
||||
{ name: "前端", count: 2 },
|
||||
{ name: "阅读", count: 1 },
|
||||
{ name: "书单", count: 1 },
|
||||
{ name: "生活", count: 2 },
|
||||
{ name: "AI", count: 1 },
|
||||
{ name: "Stable Diffusion", count: 1 },
|
||||
{ name: "Apple Silicon", count: 1 },
|
||||
{ name: "创业", count: 1 },
|
||||
{ name: "灯箱", count: 1 },
|
||||
{ name: "产品", count: 1 },
|
||||
{ name: "sui_lightbox", count: 1 },
|
||||
{ name: "随笔", count: 1 },
|
||||
{ name: "Next.js", count: 1 },
|
||||
{ name: "博客", count: 1 },
|
||||
{ name: "Halo CMS", count: 1 },
|
||||
];
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Seed script — run once to populate initial data from mock posts.
|
||||
* Usage: npx tsx src/lib/seed.ts
|
||||
*/
|
||||
import { createPost, createCategory, createTag, getPosts, getCategories, getTags } from "./store";
|
||||
|
||||
const seedPosts = [
|
||||
{
|
||||
slug: "on-writing-and-silence",
|
||||
title: "论写作与沉默",
|
||||
excerpt: "有些话适合写在纸上,有些话适合留在风里。写作不是填满空白的过程,而是从空白中提炼意义的旅程。",
|
||||
content: "<p>有些话适合写在纸上,有些话适合留在风里。</p><p>我常常觉得,沉默是一种被低估的能力。在这个信息过载的时代,我们急于表达、急于分享,却很少给自己留出沉默的空间。写作不是填满空白的过程,而是从空白中提炼意义的旅程。</p><p>每一次落笔,都是一次与自己的对话。那些在深夜里涌现的念头,像潮水一样涌来,又像退潮后的贝壳,最终留下的才是最珍贵的。</p><p>我开始学会在写作之前先沉默。让想法在脑海中沉淀,让语言在时间里发酵。好的文字从来不是急出来的。</p>",
|
||||
date: "2026-06-15",
|
||||
category: "随笔",
|
||||
tags: ["写作", "思考", "生活哲学"],
|
||||
readingTime: 4,
|
||||
featured: true,
|
||||
status: "published" as const,
|
||||
},
|
||||
{
|
||||
slug: "a-walk-in-the-mountains",
|
||||
title: "山中漫步",
|
||||
excerpt: "大别山的清晨,雾气从谷底升起,像是大地在缓缓呼吸。脚下的石板路被露水打湿,每一步都需要格外小心。",
|
||||
content: "<p>大别山的清晨,雾气从谷底升起,像是大地在缓缓呼吸。</p><p>脚下的石板路被露水打湿,每一步都需要格外小心。路旁的野花在薄雾中若隐若现,紫色和白色交替出现,像是大自然精心编排的欢迎仪式。</p><p>山里的时间过得格外慢。没有手机的信号,没有城市的喧嚣,只有鸟鸣和溪流的声音。这种安静让我想起小时候在外婆家的日子。</p>",
|
||||
date: "2026-06-10",
|
||||
category: "旅行",
|
||||
tags: ["旅行", "自然", "六安"],
|
||||
readingTime: 6,
|
||||
featured: true,
|
||||
status: "published" as const,
|
||||
},
|
||||
{
|
||||
slug: "notes-on-digital-twin",
|
||||
title: "数字孪生笔记:从3D建模到Web可视化",
|
||||
excerpt: "从 Three.js 到 React Three Fiber,Web 3D 的门槛比想象中低很多,但要做好,需要理解的东西远不止代码。",
|
||||
content: "<p>从 Three.js 到 React Three Fiber,Web 3D 的门槛比想象中低很多。</p><p>但要做好,需要理解的东西远不止代码。光照、材质、相机、性能优化,每一个都是深坑。这篇文章记录我在数字孪生项目中的一些实践和思考。</p>",
|
||||
date: "2026-06-05",
|
||||
category: "技术",
|
||||
tags: ["Web3D", "React", "Three.js", "前端"],
|
||||
readingTime: 8,
|
||||
featured: false,
|
||||
status: "published" as const,
|
||||
},
|
||||
{
|
||||
slug: "reading-list-spring",
|
||||
title: "春日书单:五本改变我看世界方式的书",
|
||||
excerpt: "春天适合读一些柔软的书。不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。",
|
||||
content: "<p>春天适合读一些柔软的书。</p><p>不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。以下五本书,在这个春天给了我很多安静的力量。</p>",
|
||||
date: "2026-05-28",
|
||||
category: "阅读",
|
||||
tags: ["阅读", "书单", "生活"],
|
||||
readingTime: 5,
|
||||
featured: true,
|
||||
status: "published" as const,
|
||||
},
|
||||
{
|
||||
slug: "stable-diffusion-local-setup",
|
||||
title: "本地部署 Stable Diffusion 踩坑记",
|
||||
excerpt: "M3 16GB 的统一内存是优势也是限制。记录在 Apple Silicon 上跑 SD 1.5 和 SDXL 的完整过程。",
|
||||
content: "<p>M3 16GB 的统一内存是优势也是限制。</p><p>这篇文章记录在 Apple Silicon 上跑 SD 1.5 和 SDXL 的完整过程,包括环境搭建、模型选择、LoRA 训练的一些尝试。</p>",
|
||||
date: "2026-05-20",
|
||||
category: "技术",
|
||||
tags: ["AI", "Stable Diffusion", "Apple Silicon"],
|
||||
readingTime: 10,
|
||||
featured: false,
|
||||
status: "published" as const,
|
||||
},
|
||||
{
|
||||
slug: "lightbox-dream",
|
||||
title: "灯箱:一个小城青年的创业梦",
|
||||
excerpt: "标识灯箱这个行业,外行人觉得简单,内行人知道水深。从3D预览到商业模式,记录 sui_lightbox 的诞生过程。",
|
||||
content: "<p>标识灯箱这个行业,外行人觉得简单,内行人知道水深。</p><p>从最初的一个想法,到3D预览原型的实现,再到商业模式的探索。这篇文章记录 sui_lightbox 项目从0到1的过程。</p>",
|
||||
date: "2026-05-12",
|
||||
category: "创业",
|
||||
tags: ["创业", "灯箱", "产品", "sui_lightbox"],
|
||||
readingTime: 7,
|
||||
featured: true,
|
||||
status: "published" as const,
|
||||
},
|
||||
{
|
||||
slug: "rainy-day-thoughts",
|
||||
title: "雨天杂记",
|
||||
excerpt: "六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。泡一壶六安瓜片,坐在窗前看雨,什么都不想。",
|
||||
content: "<p>六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。</p><p>泡一壶六安瓜片,坐在窗前看雨,什么都不想。这种无所事事的下午,反而是一周中最有创造力的时刻。</p>",
|
||||
date: "2026-05-05",
|
||||
category: "随笔",
|
||||
tags: ["随笔", "生活", "六安"],
|
||||
readingTime: 3,
|
||||
featured: false,
|
||||
status: "published" as const,
|
||||
},
|
||||
{
|
||||
slug: "next-js-blog-from-scratch",
|
||||
title: "从零搭建一个博客系统",
|
||||
excerpt: "为什么选择 Next.js + Halo CMS?为什么不用 WordPress?这篇文章聊聊技术选型的思考过程。",
|
||||
content: "<p>为什么选择 Next.js + Halo CMS?为什么不用 WordPress?</p><p>作为一个前端开发者,我希望能完全掌控博客的UI和交互体验。Halo CMS 提供了足够好的内容管理API,而 Next.js 则让我可以自由设计前端展示。</p>",
|
||||
date: "2026-04-28",
|
||||
category: "技术",
|
||||
tags: ["Next.js", "博客", "Halo CMS", "前端"],
|
||||
readingTime: 6,
|
||||
featured: false,
|
||||
status: "published" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const seedCategories = [
|
||||
{ name: "技术", description: "代码、架构与技术探索" },
|
||||
{ name: "随笔", description: "生活感悟与碎片思考" },
|
||||
{ name: "旅行", description: "在路上看到的风景与人" },
|
||||
{ name: "阅读", description: "书中世界与阅读心得" },
|
||||
{ name: "创业", description: "产品思考与创业记录" },
|
||||
];
|
||||
|
||||
const seedTags = [
|
||||
"写作", "思考", "生活哲学", "旅行", "自然", "六安",
|
||||
"Web3D", "React", "Three.js", "前端", "阅读", "书单",
|
||||
"生活", "AI", "Stable Diffusion", "Apple Silicon",
|
||||
"创业", "灯箱", "产品", "sui_lightbox", "随笔",
|
||||
"Next.js", "博客", "Halo CMS",
|
||||
];
|
||||
|
||||
// Only seed if empty
|
||||
if (getPosts().length === 0) {
|
||||
console.log("Seeding posts...");
|
||||
seedPosts.forEach((p) => createPost(p));
|
||||
console.log(` Created ${seedPosts.length} posts`);
|
||||
}
|
||||
|
||||
if (getCategories().length === 0) {
|
||||
console.log("Seeding categories...");
|
||||
seedCategories.forEach((c) => createCategory(c));
|
||||
console.log(` Created ${seedCategories.length} categories`);
|
||||
}
|
||||
|
||||
if (getTags().length === 0) {
|
||||
console.log("Seeding tags...");
|
||||
seedTags.forEach((t) => createTag({ name: t }));
|
||||
console.log(` Created ${seedTags.length} tags`);
|
||||
}
|
||||
|
||||
console.log("Seed complete.");
|
||||
@@ -0,0 +1,153 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "src/data/storage");
|
||||
|
||||
// Ensure storage directory exists
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
date: string;
|
||||
category: string;
|
||||
tags: 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;
|
||||
}
|
||||
|
||||
function readJSON<T>(filename: string, fallback: T): T {
|
||||
const filepath = path.join(DATA_DIR, filename);
|
||||
if (!existsSync(filepath)) return fallback;
|
||||
try {
|
||||
return JSON.parse(readFileSync(filepath, "utf-8"));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJSON(filename: string, data: unknown) {
|
||||
const filepath = path.join(DATA_DIR, filename);
|
||||
writeFileSync(filepath, JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
// ── Posts ──
|
||||
|
||||
export function getPosts(): Post[] {
|
||||
return readJSON<Post[]>("posts.json", []);
|
||||
}
|
||||
|
||||
export function getPost(id: string): Post | undefined {
|
||||
return getPosts().find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string): Post | undefined {
|
||||
return getPosts().find((p) => p.slug === slug);
|
||||
}
|
||||
|
||||
export function createPost(data: Omit<Post, "id" | "createdAt" | "updatedAt">): Post {
|
||||
const posts = getPosts();
|
||||
const now = new Date().toISOString();
|
||||
const post: Post = {
|
||||
...data,
|
||||
id: generateId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
posts.unshift(post);
|
||||
writeJSON("posts.json", posts);
|
||||
return post;
|
||||
}
|
||||
|
||||
export function updatePost(id: string, data: Partial<Post>): Post | null {
|
||||
const posts = getPosts();
|
||||
const index = posts.findIndex((p) => p.id === id);
|
||||
if (index === -1) return null;
|
||||
posts[index] = { ...posts[index], ...data, updatedAt: new Date().toISOString() };
|
||||
writeJSON("posts.json", posts);
|
||||
return posts[index];
|
||||
}
|
||||
|
||||
export function deletePost(id: string): boolean {
|
||||
const posts = getPosts();
|
||||
const filtered = posts.filter((p) => p.id !== id);
|
||||
if (filtered.length === posts.length) return false;
|
||||
writeJSON("posts.json", filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Categories ──
|
||||
|
||||
export function getCategories(): Category[] {
|
||||
return readJSON<Category[]>("categories.json", []);
|
||||
}
|
||||
|
||||
export function createCategory(data: Omit<Category, "id">): Category {
|
||||
const categories = getCategories();
|
||||
const cat: Category = { ...data, id: generateId() };
|
||||
categories.push(cat);
|
||||
writeJSON("categories.json", categories);
|
||||
return cat;
|
||||
}
|
||||
|
||||
export function updateCategory(id: string, data: Partial<Category>): Category | null {
|
||||
const categories = getCategories();
|
||||
const index = categories.findIndex((c) => c.id === id);
|
||||
if (index === -1) return null;
|
||||
categories[index] = { ...categories[index], ...data };
|
||||
writeJSON("categories.json", categories);
|
||||
return categories[index];
|
||||
}
|
||||
|
||||
export function deleteCategory(id: string): boolean {
|
||||
const categories = getCategories();
|
||||
const filtered = categories.filter((c) => c.id !== id);
|
||||
if (filtered.length === categories.length) return false;
|
||||
writeJSON("categories.json", filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Tags ──
|
||||
|
||||
export function getTags(): Tag[] {
|
||||
return readJSON<Tag[]>("tags.json", []);
|
||||
}
|
||||
|
||||
export function createTag(data: Omit<Tag, "id">): Tag {
|
||||
const tags = getTags();
|
||||
const tag: Tag = { ...data, id: generateId() };
|
||||
tags.push(tag);
|
||||
writeJSON("tags.json", tags);
|
||||
return tag;
|
||||
}
|
||||
|
||||
export function deleteTag(id: string): boolean {
|
||||
const tags = getTags();
|
||||
const filtered = tags.filter((t) => t.id !== id);
|
||||
if (filtered.length === tags.length) return false;
|
||||
writeJSON("tags.json", filtered);
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user