feat: 个人博客全栈实现(Rust/Axum + React/Tailwind + PostgreSQL)
后端(Axum + sqlx): - GitHub OAuth 三层权限(管理员/用户/游客),MOCK_OAUTH 本地调试开关 - 文章 CRUD、草稿自动保存、发布/私有分离,发布时渲染 HTML - 目录(folder 路径分层)、标签、评论、RSS、热门文章(按评论数) - pages 表驱动动态内容:关于页、首页标题、个人名片 - S3 兼容对象存储图片上传接口(rust-s3,未配置时明确报错) - 发布时自动提取正文首图作为封面 cover_url - sqlx migrate 管理 5 个迁移 前端(React + Tailwind): - 白天 Editorial / 黑夜星空双主题 - 首页(列表 + 热门侧栏 + 可编辑大标题)、文库(目录树+标签+搜索) - Profile 页(GitHub 风格名片卡,全部字段后台可编辑) - 后台:文件树文章管理、CodeMirror 编辑器(实时预览/自动保存/传图) - Markdown 渲染:KaTeX 公式、代码块语言标注 + 双主题语法高亮 - 有封面的文章卡片自动整行展示,全局 ErrorBoundary 部署: - docker-compose 本地三容器(postgres/backend/frontend) - k8s manifests(适配 Caddy 终结 TLS → 隧道 → traefik 明文路由) - 前端静态文件可由 Caddy 直接托管,集群只需 backend + postgres - 可选 postgres 每日备份到 S3 的 CronJob
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
DATABASE_URL=postgres://blog:blog@localhost:5432/blog
|
||||||
|
BIND_ADDR=0.0.0.0:3000
|
||||||
|
JWT_SECRET=change-me-to-a-random-string-at-least-32-chars
|
||||||
|
GITHUB_CLIENT_ID=your-github-oauth-app-client-id
|
||||||
|
GITHUB_CLIENT_SECRET=your-github-oauth-app-client-secret
|
||||||
|
GITHUB_ADMIN_ID=your-github-user-id
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/target
|
||||||
|
/backend/target
|
||||||
|
.env
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
picture/
|
||||||
|
images/
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Cosmos Blog
|
||||||
|
|
||||||
|
一个全栈个人博客系统,采用 **React + Tailwind CSS** 前端、**Rust + Axum** 后端、**PostgreSQL** 数据库,支持 **k3s/Kubernetes** 部署。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 🌌 星空暗色主题 + 杂志风排版
|
||||||
|
- 📝 CodeMirror 6 Markdown 编辑器,支持编辑/分屏/预览三种模式
|
||||||
|
- 💾 编辑器实时保存草稿
|
||||||
|
- 🔐 GitHub OAuth 登录,三层权限(guest / user / admin)
|
||||||
|
- 💬 评论系统
|
||||||
|
- 🏷️ 标签云与搜索
|
||||||
|
- 📡 RSS 订阅
|
||||||
|
- 🐳 Docker / Docker Compose 一键运行
|
||||||
|
- ☸️ k3s + Traefik Ingress 部署配置
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- [Axum](https://github.com/tokio-rs/axum) + Tokio
|
||||||
|
- [sqlx](https://github.com/launchbadge/sqlx)(PostgreSQL)
|
||||||
|
- [pulldown-cmark](https://github.com/pulldown-cmark/pulldown-cmark) 渲染 Markdown
|
||||||
|
- [jsonwebtoken](https://github.com/Keats/jsonwebtoken) 签发 JWT
|
||||||
|
- GitHub OAuth
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- React 19 + TypeScript + Vite
|
||||||
|
- Tailwind CSS + @tailwindcss/typography
|
||||||
|
- CodeMirror 6 Markdown 编辑器
|
||||||
|
- Framer Motion 动画
|
||||||
|
- TanStack Query 数据获取
|
||||||
|
- Zustand 状态管理
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── backend/ # Rust + Axum 后端
|
||||||
|
│ ├── src/
|
||||||
|
│ ├── migrations/ # sqlx 数据库迁移
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── frontend/ # React + Tailwind 前端
|
||||||
|
│ ├── src/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── nginx.conf
|
||||||
|
├── k8s/ # Kubernetes manifests
|
||||||
|
│ ├── postgres/ # PostgreSQL StatefulSet
|
||||||
|
│ ├── backend/ # 后端 Deployment
|
||||||
|
│ ├── frontend/ # 前端 Deployment
|
||||||
|
│ ├── ingress.yaml # Traefik Ingress
|
||||||
|
│ └── kustomization.yaml
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 准备环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env,填入 GitHub OAuth 参数和你的 GitHub 用户 ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Docker Compose 一键启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:http://localhost
|
||||||
|
|
||||||
|
后端 API:http://localhost:3000/api/health
|
||||||
|
|
||||||
|
### 3. 本地开发
|
||||||
|
|
||||||
|
启动 PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up postgres -d
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
启动前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 权限模型
|
||||||
|
|
||||||
|
| 角色 | 权限 |
|
||||||
|
|------|------|
|
||||||
|
| guest | 浏览公开文章、评论、标签、RSS |
|
||||||
|
| user | guest 权限 + 发表评论 |
|
||||||
|
| admin | user 权限 + 文章 CRUD、发布/私有、删除任意评论 |
|
||||||
|
|
||||||
|
管理员通过 `GITHUB_ADMIN_ID` 环境变量指定,首次 GitHub 登录时自动赋予 `admin` 角色。
|
||||||
|
|
||||||
|
## 编辑器工作流
|
||||||
|
|
||||||
|
1. 管理员在 `/admin` 点击"新建文章"。
|
||||||
|
2. 编辑器默认分屏模式:左侧 CodeMirror 编辑 Markdown,右侧实时预览。
|
||||||
|
3. 输入停止 2 秒后自动保存草稿到后端 `drafts` 表。
|
||||||
|
4. 点击"发布":将草稿渲染为 HTML,状态变为 `public`。
|
||||||
|
5. 已发布文章可转为 `private`(私有)或重新编辑。
|
||||||
|
|
||||||
|
## API 概览
|
||||||
|
|
||||||
|
### 公开接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/health` | 健康检查 |
|
||||||
|
| GET | `/api/posts?page=&tag=&q=` | 公开文章列表 |
|
||||||
|
| GET | `/api/posts/:slug` | 文章详情 |
|
||||||
|
| GET | `/api/posts/:slug/comments` | 文章评论列表 |
|
||||||
|
| GET | `/api/tags` | 标签云 |
|
||||||
|
| GET | `/api/rss.xml` | RSS Feed |
|
||||||
|
|
||||||
|
### 认证 / 用户 / 管理员接口
|
||||||
|
|
||||||
|
详见 `backend/README.md`。
|
||||||
|
|
||||||
|
## 部署到 k3s
|
||||||
|
|
||||||
|
### 1. 构建并推送镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t your-registry/blog-backend:latest ./backend
|
||||||
|
docker build -t your-registry/blog-frontend:latest ./frontend
|
||||||
|
docker push your-registry/blog-backend:latest
|
||||||
|
docker push your-registry/blog-frontend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 修改 K8s 配置
|
||||||
|
|
||||||
|
编辑以下文件中的占位符:
|
||||||
|
|
||||||
|
- `k8s/backend/secret.yaml`:数据库连接串、JWT 密钥、GitHub OAuth 参数、管理员 GitHub ID
|
||||||
|
- `k8s/postgres/secret.yaml`:PostgreSQL 密码
|
||||||
|
- `k8s/backend/deployment.yaml`:`APP_URL` 和 `FRONTEND_URL`
|
||||||
|
- `k8s/ingress.yaml`:你的域名
|
||||||
|
- `k8s/kustomization.yaml`:镜像仓库地址
|
||||||
|
|
||||||
|
### 3. 应用配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -k k8s/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 网络说明
|
||||||
|
|
||||||
|
你的场景是:**Caddy(TLS 终止)→ k3s Traefik(HTTP)→ frontend/backend**
|
||||||
|
|
||||||
|
- 集群内 Ingress 使用 `traefik.ingress.kubernetes.io/router.entrypoints: web`(HTTP 80 端口)。
|
||||||
|
- Caddy 负责 HTTPS 证书和终止 TLS,反向代理到集群的 80 端口。
|
||||||
|
- 后端不做 TLS,不需要证书配置。
|
||||||
|
- Ingress 中 `/api` 路径转发到 backend,其余路径转发到 frontend。
|
||||||
|
|
||||||
|
## 安全注意事项
|
||||||
|
|
||||||
|
1. **JWT_SECRET** 生产环境必须替换为随机强密钥。
|
||||||
|
2. **GitHub OAuth state**:当前实现将前端 redirect URL 直接作为 state,存在开放重定向风险。后续建议对 state 做签名/加密。
|
||||||
|
3. **Secret 管理**:当前用 K8s Secret 明文存储,生产建议使用 Vault / Sealed Secrets / External Secrets Operator。
|
||||||
|
4. **PostgreSQL**:k3s 内使用 StatefulSet + PVC,建议配置定期备份(如 pg_dump CronJob 或 Velero)。
|
||||||
|
|
||||||
|
## 后续可扩展
|
||||||
|
|
||||||
|
- 接入对象存储(如 MinIO/S3)保存图片/附件。
|
||||||
|
- 引入全文搜索(Meilisearch / Elasticsearch)。
|
||||||
|
- GitHub Actions CI/CD 自动构建推送镜像。
|
||||||
|
- 文章阅读量统计。
|
||||||
|
- 评论通知 / Webhook。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
.git/
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
Generated
+3629
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "blog-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7", features = ["multipart"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip"] }
|
||||||
|
sqlx = { version = "0.7", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1", features = ["serde", "v4"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
pulldown-cmark = "0.11"
|
||||||
|
rss = "2.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
thiserror = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
urlencoding = "2"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
FROM rust:bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 缓存依赖层
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
RUN mkdir src && echo 'fn main() {}' > src/main.rs
|
||||||
|
RUN cargo build --release && rm -rf src target/release/blog-backend
|
||||||
|
|
||||||
|
# 编译真实代码
|
||||||
|
COPY src ./src
|
||||||
|
COPY migrations ./migrations
|
||||||
|
RUN touch src/main.rs && cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates libssl3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/blog-backend /usr/local/bin/blog-backend
|
||||||
|
COPY --from=builder /app/migrations ./migrations
|
||||||
|
|
||||||
|
ENV BIND_ADDR=0.0.0.0:3000
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
USER nobody
|
||||||
|
CMD ["blog-backend"]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
CREATE TYPE user_role AS ENUM ('user', 'admin');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
github_id BIGINT NOT NULL UNIQUE,
|
||||||
|
email TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role user_role NOT NULL DEFAULT 'user',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
content_md TEXT NOT NULL DEFAULT '',
|
||||||
|
content_html TEXT NOT NULL DEFAULT '',
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT valid_status CHECK (status IN ('draft', 'private', 'public'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS drafts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
post_id UUID NOT NULL UNIQUE REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
content_md TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_status_published_at ON posts(status, published_at DESC NULLS LAST);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_tags ON posts USING GIN(tags);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments(post_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_drafts_post_id ON drafts(post_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_github_id ON users(github_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 文章目录:用 / 分隔的路径字符串(如 "tech/rust"),空串表示根目录
|
||||||
|
ALTER TABLE posts ADD COLUMN folder TEXT NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 独立页面(如关于页):slug 唯一,内容为 Markdown
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
content_md TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 文章封面图:发布时从 Markdown 正文提取第一张图片的 URL
|
||||||
|
ALTER TABLE posts ADD COLUMN cover_url TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- about 页更名为 profile 页
|
||||||
|
UPDATE pages SET slug = 'profile' WHERE slug = 'about';
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use axum::{
|
||||||
|
async_trait,
|
||||||
|
extract::{FromRef, FromRequestParts},
|
||||||
|
http::request::Parts,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{config::Config, error::AppError, models::UserRole, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String,
|
||||||
|
pub role: String,
|
||||||
|
pub exp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AuthUser {
|
||||||
|
Guest,
|
||||||
|
User { id: String },
|
||||||
|
Admin { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthUser {
|
||||||
|
pub fn require_admin(&self) -> crate::error::Result<String> {
|
||||||
|
match self {
|
||||||
|
AuthUser::Admin { id } => Ok(id.clone()),
|
||||||
|
_ => Err(AppError::Forbidden("admin required".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_user(&self) -> crate::error::Result<String> {
|
||||||
|
match self {
|
||||||
|
AuthUser::User { id } | AuthUser::Admin { id } => Ok(id.clone()),
|
||||||
|
AuthUser::Guest => Err(AppError::Unauthorized("login required".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_id(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
AuthUser::User { id } | AuthUser::Admin { id } => Some(id),
|
||||||
|
AuthUser::Guest => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn role_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AuthUser::Guest => "guest",
|
||||||
|
AuthUser::User { .. } => "user",
|
||||||
|
AuthUser::Admin { .. } => "admin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<S> FromRequestParts<S> for AuthUser
|
||||||
|
where
|
||||||
|
AppState: FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = Response;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let state = AppState::from_ref(state);
|
||||||
|
let token = parts
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.and_then(|h| h.strip_prefix("Bearer "))
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let Some(token) = token else {
|
||||||
|
return Ok(AuthUser::Guest);
|
||||||
|
};
|
||||||
|
|
||||||
|
let claims = decode::<Claims>(
|
||||||
|
&token,
|
||||||
|
&DecodingKey::from_secret(state.config.jwt_secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Unauthorized(format!("invalid token: {e}")).into_response())?
|
||||||
|
.claims;
|
||||||
|
|
||||||
|
match claims.role.as_str() {
|
||||||
|
"admin" => Ok(AuthUser::Admin { id: claims.sub }),
|
||||||
|
_ => Ok(AuthUser::User { id: claims.sub }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_token(user_id: &str, role: UserRole, secret: &str) -> crate::error::Result<String> {
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
role: match role {
|
||||||
|
UserRole::Admin => "admin".to_string(),
|
||||||
|
UserRole::User => "user".to_string(),
|
||||||
|
},
|
||||||
|
exp: (Utc::now() + Duration::days(7)).timestamp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn github_login_url(config: &Config, redirect: &str) -> String {
|
||||||
|
// mock 模式下不跳转 GitHub,直接回调本地接口,伪造一次授权成功
|
||||||
|
if config.mock_oauth {
|
||||||
|
return format!(
|
||||||
|
"{}/api/auth/github/callback?code=mock-code&state={}",
|
||||||
|
config.app_url.trim_end_matches('/'),
|
||||||
|
urlencoding::encode(redirect),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}/api/auth/github/callback&state={}",
|
||||||
|
urlencoding::encode(&config.github_client_id),
|
||||||
|
urlencoding::encode(&config.app_url),
|
||||||
|
urlencoding::encode(redirect),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redirect_with_token(frontend_url: &str, token: &str) -> Redirect {
|
||||||
|
Redirect::temporary(&format!("{}#token={}", frontend_url.trim_end_matches('/'), token))
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub database_url: String,
|
||||||
|
pub bind_addr: String,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub github_client_id: String,
|
||||||
|
pub github_client_secret: String,
|
||||||
|
pub github_admin_id: i64,
|
||||||
|
pub app_url: String,
|
||||||
|
pub frontend_url: String,
|
||||||
|
/// 开启后跳过真实 GitHub OAuth,直接以管理员身份签发 token(仅本地调试用)
|
||||||
|
pub mock_oauth: bool,
|
||||||
|
/// S3 兼容对象存储配置,s3_endpoint 为空表示未启用上传
|
||||||
|
pub s3_endpoint: String,
|
||||||
|
pub s3_region: String,
|
||||||
|
pub s3_bucket: String,
|
||||||
|
pub s3_access_key: String,
|
||||||
|
pub s3_secret_key: String,
|
||||||
|
/// 上传后对外访问的 URL 前缀(如 https://cdn.example.com/bucket)
|
||||||
|
pub s3_public_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
database_url: env::var("DATABASE_URL")?,
|
||||||
|
bind_addr: env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".to_string()),
|
||||||
|
jwt_secret: env::var("JWT_SECRET")?,
|
||||||
|
github_client_id: env::var("GITHUB_CLIENT_ID")?,
|
||||||
|
github_client_secret: env::var("GITHUB_CLIENT_SECRET")?,
|
||||||
|
github_admin_id: env::var("GITHUB_ADMIN_ID")?.parse::<i64>()?,
|
||||||
|
app_url: env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()),
|
||||||
|
frontend_url: env::var("FRONTEND_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:5173".to_string()),
|
||||||
|
mock_oauth: env::var("MOCK_OAUTH")
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false),
|
||||||
|
s3_endpoint: env::var("S3_ENDPOINT").unwrap_or_default(),
|
||||||
|
s3_region: env::var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
|
||||||
|
s3_bucket: env::var("S3_BUCKET").unwrap_or_default(),
|
||||||
|
s3_access_key: env::var("S3_ACCESS_KEY").unwrap_or_default(),
|
||||||
|
s3_secret_key: env::var("S3_SECRET_KEY").unwrap_or_default(),
|
||||||
|
s3_public_url: env::var("S3_PUBLIC_URL").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, AppError>;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
#[error("unauthorized: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
|
#[error("forbidden: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
#[error("internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
AppError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
|
||||||
|
AppError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m.clone()),
|
||||||
|
AppError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
|
||||||
|
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||||
|
AppError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = Json(json!({ "error": message }));
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for AppError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
match e {
|
||||||
|
sqlx::Error::RowNotFound => AppError::NotFound("resource not found".to_string()),
|
||||||
|
sqlx::Error::Database(db) if db.constraint().is_some() => {
|
||||||
|
AppError::BadRequest(format!("constraint violation: {}", db.message()))
|
||||||
|
}
|
||||||
|
_ => AppError::Internal(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for AppError {
|
||||||
|
fn from(e: anyhow::Error) -> Self {
|
||||||
|
AppError::Internal(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for AppError {
|
||||||
|
fn from(e: reqwest::Error) -> Self {
|
||||||
|
AppError::Internal(format!("http client error: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<jsonwebtoken::errors::Error> for AppError {
|
||||||
|
fn from(e: jsonwebtoken::errors::Error) -> Self {
|
||||||
|
AppError::Internal(format!("jwt error: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod markdown;
|
||||||
|
mod models;
|
||||||
|
mod routes;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::{
|
||||||
|
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||||
|
Method,
|
||||||
|
},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use tower_http::{
|
||||||
|
compression::CompressionLayer,
|
||||||
|
cors::{AllowOrigin, CorsLayer},
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use crate::{config::Config, state::AppState};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "blog_backend=debug,tower_http=debug".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
tracing::info!("starting blog backend");
|
||||||
|
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(20)
|
||||||
|
.connect(&config.database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await?;
|
||||||
|
tracing::info!("database migrations applied");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: config.clone(),
|
||||||
|
pool,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(AllowOrigin::exact(config.frontend_url.parse()?))
|
||||||
|
.allow_methods([
|
||||||
|
Method::GET,
|
||||||
|
Method::POST,
|
||||||
|
Method::PUT,
|
||||||
|
Method::DELETE,
|
||||||
|
Method::OPTIONS,
|
||||||
|
])
|
||||||
|
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE])
|
||||||
|
.allow_credentials(true);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.merge(routes::router())
|
||||||
|
.layer(cors)
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
|
||||||
|
tracing::info!("listening on {}", config.bind_addr);
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use pulldown_cmark::{html, Event, Options, Parser, Tag};
|
||||||
|
|
||||||
|
pub fn render(content: &str) -> String {
|
||||||
|
let mut opts = Options::empty();
|
||||||
|
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||||
|
opts.insert(Options::ENABLE_TABLES);
|
||||||
|
opts.insert(Options::ENABLE_FOOTNOTES);
|
||||||
|
opts.insert(Options::ENABLE_TASKLISTS);
|
||||||
|
opts.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||||
|
|
||||||
|
let parser = Parser::new_ext(content, opts);
|
||||||
|
let mut html_output = String::new();
|
||||||
|
html::push_html(&mut html_output, parser);
|
||||||
|
html_output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提取正文里第一张图片的 URL 作为封面,没有图片返回 None
|
||||||
|
pub fn extract_cover_url(content: &str) -> Option<String> {
|
||||||
|
Parser::new(content).find_map(|event| match event {
|
||||||
|
Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url.to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub github_id: i64,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub role: UserRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
|
||||||
|
pub enum UserRole {
|
||||||
|
User,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Post {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub content_md: String,
|
||||||
|
pub content_html: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub folder: String,
|
||||||
|
pub cover_url: Option<String>,
|
||||||
|
pub status: PostStatus,
|
||||||
|
pub author_id: Uuid,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub published_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PostStatus {
|
||||||
|
Draft,
|
||||||
|
Private,
|
||||||
|
Public,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Draft {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub post_id: Uuid,
|
||||||
|
pub content_md: String,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct PostSummary {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub status: PostStatus,
|
||||||
|
pub published_at: Option<DateTime<Utc>>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub folder: String,
|
||||||
|
pub cover_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PostDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub content_md: String,
|
||||||
|
pub status: PostStatus,
|
||||||
|
pub published_at: Option<DateTime<Utc>>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub folder: String,
|
||||||
|
pub cover_url: Option<String>,
|
||||||
|
pub author: Author,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Author {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CommentView {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub author: Author,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TagCount {
|
||||||
|
pub name: String,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct FolderCount {
|
||||||
|
pub name: String,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct HotPost {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub post: PostSummary,
|
||||||
|
pub comment_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ArchiveItem {
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
pub folder: String,
|
||||||
|
pub published_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, Result},
|
||||||
|
markdown,
|
||||||
|
models::{Author, Post, PostDetail, PostSummary},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/admin/posts", post(create_post).get(list_posts))
|
||||||
|
.route("/api/admin/posts/:id", get(get_post).put(update_post).delete(delete_post))
|
||||||
|
.route("/api/admin/posts/:id/draft", post(save_draft).get(get_draft))
|
||||||
|
.route("/api/admin/posts/:id/publish", post(publish_post))
|
||||||
|
.route("/api/admin/posts/:id/unpublish", post(unpublish_post))
|
||||||
|
.route("/api/admin/preview", post(preview))
|
||||||
|
.route("/api/admin/comments/:id", delete(delete_comment))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_posts(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Vec<PostSummary>>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
let posts = sqlx::query_as::<_, PostSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT id, slug, title, summary, status, published_at, tags, folder, cover_url
|
||||||
|
FROM posts
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(posts))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<PostDetail>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id, p.slug, p.title, p.summary, p.content_md, p.status,
|
||||||
|
p.published_at, p.tags, p.folder, p.cover_url,
|
||||||
|
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON p.author_id = u.id
|
||||||
|
WHERE p.id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("post not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(row_to_post_detail(row)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreatePostBody {
|
||||||
|
slug: String,
|
||||||
|
title: String,
|
||||||
|
summary: Option<String>,
|
||||||
|
tags: Option<Vec<String>>,
|
||||||
|
folder: Option<String>,
|
||||||
|
content_md: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(body): Json<CreatePostBody>,
|
||||||
|
) -> Result<Json<Post>> {
|
||||||
|
let admin_id = auth.require_admin()?;
|
||||||
|
let admin_uuid = Uuid::parse_str(&admin_id).map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||||
|
|
||||||
|
let slug = body.slug.trim().to_string();
|
||||||
|
let title = body.title.trim().to_string();
|
||||||
|
if slug.is_empty() || title.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("slug and title are required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags: Vec<String> = body
|
||||||
|
.tags
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| t.trim().to_lowercase())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let content_md = body.content_md.unwrap_or_default();
|
||||||
|
let content_html = markdown::render(&content_md);
|
||||||
|
let cover_url = markdown::extract_cover_url(&content_md);
|
||||||
|
let folder = normalize_folder(body.folder.as_deref());
|
||||||
|
|
||||||
|
let post: Post = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO posts (slug, title, summary, tags, folder, cover_url, content_md, content_html, author_id, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft')
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(&title)
|
||||||
|
.bind(body.summary.as_ref())
|
||||||
|
.bind(&tags)
|
||||||
|
.bind(&folder)
|
||||||
|
.bind(cover_url.as_ref())
|
||||||
|
.bind(&content_md)
|
||||||
|
.bind(&content_html)
|
||||||
|
.bind(admin_uuid)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(post))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdatePostBody {
|
||||||
|
slug: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
|
summary: Option<String>,
|
||||||
|
tags: Option<Vec<String>>,
|
||||||
|
folder: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规范化目录路径:去掉首尾斜杠和空白,"a//b/" -> "a/b"
|
||||||
|
fn normalize_folder(input: Option<&str>) -> String {
|
||||||
|
input
|
||||||
|
.unwrap_or("")
|
||||||
|
.split('/')
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<UpdatePostBody>,
|
||||||
|
) -> Result<Json<Post>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
let post: Post = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
UPDATE posts
|
||||||
|
SET slug = COALESCE($2, slug),
|
||||||
|
title = COALESCE($3, title),
|
||||||
|
summary = COALESCE($4, summary),
|
||||||
|
tags = COALESCE($5, tags),
|
||||||
|
folder = COALESCE($6, folder),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(
|
||||||
|
body.slug
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty()),
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
body.title
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty()),
|
||||||
|
)
|
||||||
|
.bind(body.summary.as_ref())
|
||||||
|
.bind(body.tags.as_ref().map(|tags| {
|
||||||
|
tags.iter()
|
||||||
|
.map(|t| t.trim().to_lowercase())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}))
|
||||||
|
.bind(body.folder.as_deref().map(|f| normalize_folder(Some(f))))
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(post))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM posts WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SaveDraftBody {
|
||||||
|
content_md: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_draft(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<SaveDraftBody>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO drafts (post_id, content_md, updated_at)
|
||||||
|
VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (post_id) DO UPDATE SET
|
||||||
|
content_md = EXCLUDED.content_md,
|
||||||
|
updated_at = NOW()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&body.content_md)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_draft(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
let draft: Option<crate::models::Draft> = sqlx::query_as("SELECT * FROM drafts WHERE post_id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"content_md": draft.map(|d| d.content_md).unwrap_or_default(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn publish_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<PostDetail>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
let draft: Option<crate::models::Draft> = sqlx::query_as("SELECT * FROM drafts WHERE post_id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let content_md = if let Some(d) = draft {
|
||||||
|
d.content_md
|
||||||
|
} else {
|
||||||
|
let row = sqlx::query("SELECT content_md FROM posts WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("post not found".to_string()))?;
|
||||||
|
row.get::<String, _>("content_md")
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_html = markdown::render(&content_md);
|
||||||
|
let cover_url = markdown::extract_cover_url(&content_md);
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE posts
|
||||||
|
SET content_md = $2,
|
||||||
|
content_html = $3,
|
||||||
|
cover_url = $4,
|
||||||
|
status = 'public',
|
||||||
|
published_at = COALESCE(published_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM users u
|
||||||
|
WHERE posts.id = $1 AND u.id = posts.author_id
|
||||||
|
RETURNING
|
||||||
|
posts.id, posts.slug, posts.title, posts.summary,
|
||||||
|
posts.content_md, posts.status,
|
||||||
|
posts.published_at, posts.tags, posts.folder, posts.cover_url,
|
||||||
|
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&content_md)
|
||||||
|
.bind(&content_html)
|
||||||
|
.bind(cover_url.as_ref())
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(row_to_post_detail(row)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unpublish_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<PostDetail>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE posts
|
||||||
|
SET status = 'private', updated_at = NOW()
|
||||||
|
FROM users u
|
||||||
|
WHERE posts.id = $1 AND u.id = posts.author_id
|
||||||
|
RETURNING
|
||||||
|
posts.id, posts.slug, posts.title, posts.summary,
|
||||||
|
posts.content_md, posts.status,
|
||||||
|
posts.published_at, posts.tags, posts.folder, posts.cover_url,
|
||||||
|
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(row_to_post_detail(row)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PreviewBody {
|
||||||
|
content_md: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn preview(auth: AuthUser, Json(body): Json<PreviewBody>) -> Result<Json<serde_json::Value>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
let html = markdown::render(&body.content_md);
|
||||||
|
Ok(Json(serde_json::json!({ "content_html": html })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_comment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
sqlx::query("UPDATE comments SET deleted_at = NOW() WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_post_detail(row: sqlx::postgres::PgRow) -> Result<PostDetail> {
|
||||||
|
Ok(PostDetail {
|
||||||
|
id: row.try_get("id").map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
slug: row.try_get("slug").map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
title: row.try_get("title").map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
summary: row.try_get("summary").map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
content_md: row.try_get("content_md").map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
status: row.try_get("status").map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
published_at: row
|
||||||
|
.try_get("published_at")
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
tags: row.try_get("tags").map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
folder: row
|
||||||
|
.try_get("folder")
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
cover_url: row
|
||||||
|
.try_get("cover_url")
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
author: Author {
|
||||||
|
id: row.try_get("author_id").map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
name: row
|
||||||
|
.try_get("author_name")
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
avatar_url: row
|
||||||
|
.try_get("author_avatar")
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::Redirect,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use reqwest::header;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{issue_token, redirect_with_token, AuthUser},
|
||||||
|
error::{AppError, Result},
|
||||||
|
models::{Author, User, UserRole},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/auth/github", get(github_login))
|
||||||
|
.route("/api/auth/github/callback", get(github_callback))
|
||||||
|
.route("/api/auth/me", get(me))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginQuery {
|
||||||
|
redirect: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn github_login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<LoginQuery>,
|
||||||
|
) -> Result<Redirect> {
|
||||||
|
let redirect = q.redirect.unwrap_or_else(|| state.config.frontend_url.clone());
|
||||||
|
let frontend = state.config.frontend_url.trim_end_matches('/');
|
||||||
|
|
||||||
|
// 白名单校验:redirect 必须以 frontend_url 为前缀
|
||||||
|
if !redirect.starts_with(frontend) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"invalid redirect url".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = crate::auth::github_login_url(&state.config, &redirect);
|
||||||
|
Ok(Redirect::temporary(&url))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CallbackQuery {
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GitHubTokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GitHubUser {
|
||||||
|
id: i64,
|
||||||
|
login: String,
|
||||||
|
email: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn github_callback(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<CallbackQuery>,
|
||||||
|
) -> Result<Redirect> {
|
||||||
|
// 校验 state 必须是 frontend_url 前缀
|
||||||
|
let frontend = state.config.frontend_url.trim_end_matches('/');
|
||||||
|
if !q.state.starts_with(frontend) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"invalid oauth state".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let github_user: GitHubUser = if state.config.mock_oauth {
|
||||||
|
// mock 模式:跳过 GitHub 令牌交换,伪造一个管理员用户
|
||||||
|
GitHubUser {
|
||||||
|
id: state.config.github_admin_id,
|
||||||
|
login: "mock-admin".to_string(),
|
||||||
|
email: Some("mock-admin@localhost".to_string()),
|
||||||
|
avatar_url: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let token_response = client
|
||||||
|
.post("https://github.com/login/oauth/access_token")
|
||||||
|
.header(header::ACCEPT, "application/json")
|
||||||
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||||
|
.body(format!(
|
||||||
|
"client_id={}&client_secret={}&code={}",
|
||||||
|
urlencoding::encode(&state.config.github_client_id),
|
||||||
|
urlencoding::encode(&state.config.github_client_secret),
|
||||||
|
urlencoding::encode(&q.code)
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("github token request failed: {e}")))?;
|
||||||
|
|
||||||
|
let status = token_response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let text = token_response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "unknown".to_string());
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"github token error {}: {}",
|
||||||
|
status.as_u16(),
|
||||||
|
text
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_res: GitHubTokenResponse = token_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("github token decode failed: {e}")))?;
|
||||||
|
|
||||||
|
let user_response = client
|
||||||
|
.get("https://api.github.com/user")
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {}", token_res.access_token))
|
||||||
|
.header(header::USER_AGENT, "blog-backend")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("github user request failed: {e}")))?;
|
||||||
|
|
||||||
|
let user_status = user_response.status();
|
||||||
|
if !user_status.is_success() {
|
||||||
|
let text = user_response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "unknown".to_string());
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"github user error {}: {}",
|
||||||
|
user_status.as_u16(),
|
||||||
|
text
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
user_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("github user decode failed: {e}")))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let role = if github_user.id == state.config.github_admin_id {
|
||||||
|
UserRole::Admin
|
||||||
|
} else {
|
||||||
|
UserRole::User
|
||||||
|
};
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (github_id, name, email, avatar_url, role)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (github_id) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
avatar_url = EXCLUDED.avatar_url,
|
||||||
|
role = EXCLUDED.role,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(github_user.id)
|
||||||
|
.bind(&github_user.login)
|
||||||
|
.bind(github_user.email.as_ref())
|
||||||
|
.bind(github_user.avatar_url.as_ref())
|
||||||
|
.bind(role.clone())
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user_id: uuid::Uuid = row.get("id");
|
||||||
|
let token = issue_token(&user_id.to_string(), role, &state.config.jwt_secret)?;
|
||||||
|
|
||||||
|
Ok(redirect_with_token(&q.state, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
let user_id = match auth.user_id() {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return Ok(Json(json!({"role": "guest"}))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let uuid = uuid::Uuid::parse_str(user_id).map_err(|e| AppError::Unauthorized(e.to_string()))?;
|
||||||
|
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1")
|
||||||
|
.bind(uuid)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"role": auth.role_name(),
|
||||||
|
"user": Author {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use axum::Router;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub mod admin;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod pages;
|
||||||
|
pub mod public;
|
||||||
|
pub mod upload;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.merge(public::router())
|
||||||
|
.merge(auth::router())
|
||||||
|
.merge(user::router())
|
||||||
|
.merge(admin::router())
|
||||||
|
.merge(pages::router())
|
||||||
|
.merge(upload::router())
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::Result,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/pages/:slug", get(get_page))
|
||||||
|
.route("/api/admin/pages/:slug", axum::routing::put(put_page))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
let row = sqlx::query("SELECT content_md, updated_at FROM pages WHERE slug = $1")
|
||||||
|
.bind(&slug)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (content_md, updated_at) = match row {
|
||||||
|
Some(r) => (
|
||||||
|
r.get::<String, _>("content_md"),
|
||||||
|
r.get::<chrono::DateTime<chrono::Utc>, _>("updated_at"),
|
||||||
|
),
|
||||||
|
None => (String::new(), chrono::Utc::now()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"slug": slug,
|
||||||
|
"content_md": content_md,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PutPageBody {
|
||||||
|
content_md: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(body): Json<PutPageBody>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO pages (slug, content_md, updated_at)
|
||||||
|
VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
|
content_md = EXCLUDED.content_md,
|
||||||
|
updated_at = NOW()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(&body.content_md)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::header::CONTENT_TYPE,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use rss::{ChannelBuilder, ItemBuilder};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{AppError, Result},
|
||||||
|
models::{ArchiveItem, Author, CommentView, FolderCount, HotPost, PostDetail, PostSummary, TagCount},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListPostsQuery {
|
||||||
|
tag: Option<String>,
|
||||||
|
q: Option<String>,
|
||||||
|
folder: Option<String>,
|
||||||
|
page: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/health", get(health))
|
||||||
|
.route("/api/posts", get(list_posts))
|
||||||
|
.route("/api/posts/hot", get(hot_posts))
|
||||||
|
.route("/api/posts/:slug", get(get_post))
|
||||||
|
.route("/api/posts/:slug/comments", get(list_comments))
|
||||||
|
.route("/api/tags", get(list_tags))
|
||||||
|
.route("/api/folders", get(list_folders))
|
||||||
|
.route("/api/archive", get(archive))
|
||||||
|
.route("/api/rss.xml", get(rss_feed))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health() -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE: i64 = 20;
|
||||||
|
|
||||||
|
async fn list_posts(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<ListPostsQuery>,
|
||||||
|
) -> Result<Json<Vec<PostSummary>>> {
|
||||||
|
let page = q.page.unwrap_or(1).max(1);
|
||||||
|
let offset = (page - 1) * PAGE_SIZE;
|
||||||
|
let tag = q.tag.as_deref().filter(|s| !s.is_empty());
|
||||||
|
let keyword = q.q.as_deref().filter(|s| !s.is_empty());
|
||||||
|
let folder = q.folder.as_deref().filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let posts = sqlx::query_as::<_, PostSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT id, slug, title, summary, status, published_at, tags, folder, cover_url
|
||||||
|
FROM posts
|
||||||
|
WHERE status = 'public'
|
||||||
|
AND ($1::text IS NULL OR $1 = ANY(tags))
|
||||||
|
AND ($2::text IS NULL OR title ILIKE '%' || $2 || '%' OR summary ILIKE '%' || $2 || '%')
|
||||||
|
AND ($3::text IS NULL OR folder = $3 OR folder LIKE $3 || '/%')
|
||||||
|
ORDER BY published_at DESC NULLS LAST
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(tag)
|
||||||
|
.bind(keyword)
|
||||||
|
.bind(folder)
|
||||||
|
.bind(PAGE_SIZE)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(posts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 归档:所有公开文章的轻量列表(无正文),供前台构建完整文件树
|
||||||
|
async fn archive(State(state): State<AppState>) -> Result<Json<Vec<ArchiveItem>>> {
|
||||||
|
let items = sqlx::query_as::<_, ArchiveItem>(
|
||||||
|
r#"
|
||||||
|
SELECT slug, title, folder, published_at
|
||||||
|
FROM posts
|
||||||
|
WHERE status = 'public'
|
||||||
|
ORDER BY folder, published_at DESC NULLS LAST
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目录列表:公开文章按 folder 分组计数
|
||||||
|
async fn list_folders(State(state): State<AppState>) -> Result<Json<Vec<FolderCount>>> {
|
||||||
|
let folders = sqlx::query_as::<_, FolderCount>(
|
||||||
|
r#"
|
||||||
|
SELECT folder AS name, COUNT(*) AS count
|
||||||
|
FROM posts
|
||||||
|
WHERE status = 'public'
|
||||||
|
GROUP BY folder
|
||||||
|
ORDER BY folder
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(folders))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 热门文章:按评论数排序取前 5
|
||||||
|
async fn hot_posts(State(state): State<AppState>) -> Result<Json<Vec<HotPost>>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id, p.slug, p.title, p.summary, p.status, p.published_at, p.tags, p.folder, p.cover_url,
|
||||||
|
COUNT(c.id) AS comment_count
|
||||||
|
FROM posts p
|
||||||
|
LEFT JOIN comments c ON c.post_id = p.id AND c.deleted_at IS NULL
|
||||||
|
WHERE p.status = 'public'
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY comment_count DESC, p.published_at DESC NULLS LAST
|
||||||
|
LIMIT 5
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let posts = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| HotPost {
|
||||||
|
post: PostSummary {
|
||||||
|
id: r.get("id"),
|
||||||
|
slug: r.get("slug"),
|
||||||
|
title: r.get("title"),
|
||||||
|
summary: r.get("summary"),
|
||||||
|
status: r.get("status"),
|
||||||
|
published_at: r.get("published_at"),
|
||||||
|
tags: r.get("tags"),
|
||||||
|
folder: r.get("folder"),
|
||||||
|
cover_url: r.get("cover_url"),
|
||||||
|
},
|
||||||
|
comment_count: r.get("comment_count"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(posts))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<PostDetail>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id, p.slug, p.title, p.summary, p.content_md, p.status,
|
||||||
|
p.published_at, p.tags, p.folder, p.cover_url,
|
||||||
|
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON p.author_id = u.id
|
||||||
|
WHERE p.slug = $1 AND p.status = 'public'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&slug)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("post '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
let post = PostDetail {
|
||||||
|
id: row.get("id"),
|
||||||
|
slug: row.get("slug"),
|
||||||
|
title: row.get("title"),
|
||||||
|
summary: row.get("summary"),
|
||||||
|
content_md: row.get("content_md"),
|
||||||
|
status: row.get("status"),
|
||||||
|
published_at: row.get("published_at"),
|
||||||
|
tags: row.get("tags"),
|
||||||
|
folder: row.get("folder"),
|
||||||
|
cover_url: row.get("cover_url"),
|
||||||
|
author: Author {
|
||||||
|
id: row.get("author_id"),
|
||||||
|
name: row.get("author_name"),
|
||||||
|
avatar_url: row.get("author_avatar"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(post))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_comments(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<CommentView>>> {
|
||||||
|
let comments = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
c.id, c.content, c.created_at,
|
||||||
|
u.id as author_id, u.name as author_name, u.avatar_url as author_avatar
|
||||||
|
FROM comments c
|
||||||
|
JOIN posts p ON c.post_id = p.id
|
||||||
|
JOIN users u ON c.user_id = u.id
|
||||||
|
WHERE p.slug = $1 AND p.status = 'public' AND c.deleted_at IS NULL
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&slug)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let views = comments
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| CommentView {
|
||||||
|
id: r.get("id"),
|
||||||
|
content: r.get("content"),
|
||||||
|
created_at: r.get("created_at"),
|
||||||
|
author: Author {
|
||||||
|
id: r.get("author_id"),
|
||||||
|
name: r.get("author_name"),
|
||||||
|
avatar_url: r.get("author_avatar"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(views))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_tags(State(state): State<AppState>) -> Result<Json<Vec<TagCount>>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT tag AS name, COUNT(*) AS count
|
||||||
|
FROM posts, UNNEST(tags) AS tag
|
||||||
|
WHERE status = 'public'
|
||||||
|
GROUP BY tag
|
||||||
|
ORDER BY count DESC, tag ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let tags = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| TagCount {
|
||||||
|
name: r.get("name"),
|
||||||
|
count: r.get("count"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rss_feed(State(state): State<AppState>) -> Result<Response> {
|
||||||
|
let posts = sqlx::query_as::<_, PostSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT id, slug, title, summary, status, published_at, tags, folder, cover_url
|
||||||
|
FROM posts
|
||||||
|
WHERE status = 'public' AND published_at IS NOT NULL
|
||||||
|
ORDER BY published_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items: Vec<_> = posts
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|p| {
|
||||||
|
let pub_date = p.published_at?;
|
||||||
|
Some(
|
||||||
|
ItemBuilder::default()
|
||||||
|
.title(p.title)
|
||||||
|
.link(format!(
|
||||||
|
"{}/posts/{}",
|
||||||
|
state.config.frontend_url.trim_end_matches('/'),
|
||||||
|
p.slug
|
||||||
|
))
|
||||||
|
.description(p.summary.unwrap_or_default())
|
||||||
|
.pub_date(pub_date.to_rfc2822())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let channel = ChannelBuilder::default()
|
||||||
|
.title("Blog")
|
||||||
|
.link(state.config.frontend_url.clone())
|
||||||
|
.description("Personal blog powered by Rust & React")
|
||||||
|
.last_build_date(Utc::now().to_rfc2822())
|
||||||
|
.items(items)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Ok(([(CONTENT_TYPE, "application/rss+xml; charset=utf-8")], channel.to_string()).into_response())
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{DefaultBodyLimit, Multipart, State},
|
||||||
|
routing::post,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use s3::{creds::Credentials, Bucket, Region};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, Result},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 单文件大小上限 15MB
|
||||||
|
const MAX_UPLOAD_BYTES: usize = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/admin/upload", post(upload))
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
auth.require_admin()?;
|
||||||
|
|
||||||
|
let cfg = &state.config;
|
||||||
|
if cfg.s3_endpoint.is_empty() || cfg.s3_bucket.is_empty() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"object storage is not configured".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let field = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::BadRequest(format!("invalid multipart body: {e}")))?
|
||||||
|
.ok_or_else(|| AppError::BadRequest("missing file field".to_string()))?;
|
||||||
|
|
||||||
|
let content_type = field
|
||||||
|
.content_type()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
if !content_type.starts_with("image/") {
|
||||||
|
return Err(AppError::BadRequest("only image uploads are allowed".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = field
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.rsplit('.').next().map(|e| e.to_lowercase()))
|
||||||
|
.filter(|e| e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||||
|
.unwrap_or_else(|| "png".to_string());
|
||||||
|
|
||||||
|
let data = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::BadRequest(format!("failed to read upload: {e}")))?;
|
||||||
|
|
||||||
|
let region = Region::Custom {
|
||||||
|
region: cfg.s3_region.clone(),
|
||||||
|
endpoint: cfg.s3_endpoint.clone(),
|
||||||
|
};
|
||||||
|
let credentials = Credentials::new(
|
||||||
|
Some(&cfg.s3_access_key),
|
||||||
|
Some(&cfg.s3_secret_key),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Internal(format!("invalid s3 credentials: {e}")))?;
|
||||||
|
let bucket = Bucket::new(&cfg.s3_bucket, region, credentials)
|
||||||
|
.map_err(|e| AppError::Internal(format!("invalid s3 bucket config: {e}")))?
|
||||||
|
.with_path_style();
|
||||||
|
|
||||||
|
let key = format!("uploads/{}.{}", uuid::Uuid::new_v4(), ext);
|
||||||
|
bucket
|
||||||
|
.put_object_with_content_type(&key, &data, &content_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("s3 upload failed: {e}")))?;
|
||||||
|
|
||||||
|
let base = cfg.s3_public_url.trim_end_matches('/');
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"url": format!("{}/{}", base, key),
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::post,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, Result},
|
||||||
|
models::CommentView,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new().route("/api/posts/:slug/comments", post(create_comment))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateCommentBody {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_comment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(body): Json<CreateCommentBody>,
|
||||||
|
) -> Result<Json<CommentView>> {
|
||||||
|
let user_id = auth.require_user()?;
|
||||||
|
let user_uuid = Uuid::parse_str(&user_id).map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||||
|
|
||||||
|
let content = body.content.trim();
|
||||||
|
if content.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("comment content is empty".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO comments (post_id, user_id, content)
|
||||||
|
SELECT p.id, $2, $3
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.slug = $1 AND p.status = 'public'
|
||||||
|
RETURNING
|
||||||
|
comments.id,
|
||||||
|
comments.content,
|
||||||
|
comments.created_at,
|
||||||
|
$2 as user_id,
|
||||||
|
(SELECT name FROM users WHERE id = $2) as author_name,
|
||||||
|
(SELECT avatar_url FROM users WHERE id = $2) as author_avatar
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(user_uuid)
|
||||||
|
.bind(content)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("public post '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
Ok(Json(CommentView {
|
||||||
|
id: row.get("id"),
|
||||||
|
content: row.get("content"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
author: crate::models::Author {
|
||||||
|
id: user_uuid,
|
||||||
|
name: row.get("author_name"),
|
||||||
|
avatar_url: row.get("author_avatar"),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Config,
|
||||||
|
pub pool: PgPool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: blog
|
||||||
|
POSTGRES_PASSWORD: blog
|
||||||
|
POSTGRES_DB: blog
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U blog -d blog"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://blog:blog@postgres:5432/blog
|
||||||
|
BIND_ADDR: 0.0.0.0:3000
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
|
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID}
|
||||||
|
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
|
||||||
|
GITHUB_ADMIN_ID: ${GITHUB_ADMIN_ID}
|
||||||
|
APP_URL: ${APP_URL:-http://localhost:9001}
|
||||||
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:9000}
|
||||||
|
MOCK_OAUTH: ${MOCK_OAUTH:-false}
|
||||||
|
ports:
|
||||||
|
- "9001:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "9000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
|
/.vite
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 构建阶段
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 运行阶段
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM mcr.microsoft.com/playwright:v1.49.0-jammy
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY screenshot.cjs /app/screenshot.cjs
|
||||||
|
|
||||||
|
# 挂载中文字体
|
||||||
|
VOLUME /usr/share/fonts
|
||||||
|
|
||||||
|
CMD ["node", "screenshot.cjs"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN" translate="no">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="google" content="notranslate" />
|
||||||
|
<title>zhaoxi的个人博客</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4790
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/lang-markdown": "^6.5.1",
|
||||||
|
"@codemirror/state": "^6.7.1",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.43.6",
|
||||||
|
"@tanstack/react-query": "^5.101.2",
|
||||||
|
"@uiw/react-codemirror": "^4.25.11",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"framer-motion": "^12.42.2",
|
||||||
|
"github-markdown-css": "^5.9.0",
|
||||||
|
"katex": "^0.18.0",
|
||||||
|
"lucide-react": "^0.474.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.18.1",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"autoprefixer": "^10.5.4",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
|
"postcss": "^8.5.19",
|
||||||
|
"prettier": "^3.9.5",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#818CF8"/>
|
||||||
|
<stop offset="0.5" stop-color="#A78BFA"/>
|
||||||
|
<stop offset="1" stop-color="#22D3EE"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="#0B1026"/>
|
||||||
|
<path d="M32 8 L36.5 27.5 L56 32 L36.5 36.5 L32 56 L27.5 36.5 L8 32 L27.5 27.5 Z" fill="url(#g)"/>
|
||||||
|
<circle cx="47" cy="15" r="2.5" fill="#818CF8" opacity="0.8"/>
|
||||||
|
<circle cx="15" cy="47" r="2" fill="#22D3EE" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 590 B |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
|
||||||
|
// 强制浅色模式
|
||||||
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
|
await page.goto('http://localhost:9000', { waitUntil: 'networkidle' });
|
||||||
|
await page.screenshot({ path: '/output/05-light-editorial.png', fullPage: false });
|
||||||
|
console.log('captured 05-light-editorial.png');
|
||||||
|
|
||||||
|
// 强制深色模式
|
||||||
|
await page.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await page.screenshot({ path: '/output/06-dark-space.png', fullPage: false });
|
||||||
|
console.log('captured 06-dark-space.png');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('done');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { StarBackground } from './components/StarBackground';
|
||||||
|
import { Header } from './components/Header';
|
||||||
|
import { AuthInit } from './components/AuthInit';
|
||||||
|
import { HomePage } from './pages/HomePage';
|
||||||
|
import { PostPage } from './pages/PostPage';
|
||||||
|
import { LibraryPage } from './pages/LibraryPage';
|
||||||
|
import { ProfilePage } from './pages/ProfilePage';
|
||||||
|
import { RssPage } from './pages/RssPage';
|
||||||
|
import { OAuthCallback } from './pages/OAuthCallback';
|
||||||
|
import { AdminPage } from './pages/admin/AdminPage';
|
||||||
|
import { EditorPage } from './pages/admin/EditorPage';
|
||||||
|
import { AdminProfilePage } from './pages/admin/AdminProfilePage';
|
||||||
|
import { AdminHeroPage } from './pages/admin/AdminHeroPage';
|
||||||
|
import { AdminLayout } from './pages/admin/AdminLayout';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthInit>
|
||||||
|
<StarBackground>
|
||||||
|
<Header />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/posts/:slug" element={<PostPage />} />
|
||||||
|
<Route path="/library" element={<LibraryPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/about" element={<Navigate to="/profile" replace />} />
|
||||||
|
<Route path="/rss" element={<RssPage />} />
|
||||||
|
<Route path="/archive" element={<Navigate to="/library" replace />} />
|
||||||
|
<Route path="/tags" element={<Navigate to="/library" replace />} />
|
||||||
|
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||||
|
<Route element={<AdminLayout />}>
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
<Route path="/admin/new" element={<EditorPage />} />
|
||||||
|
<Route path="/admin/edit/:id" element={<EditorPage />} />
|
||||||
|
<Route path="/admin/profile" element={<AdminProfilePage />} />
|
||||||
|
<Route path="/admin/about" element={<Navigate to="/admin/profile" replace />} />
|
||||||
|
<Route path="/admin/hero" element={<AdminHeroPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</StarBackground>
|
||||||
|
</AuthInit>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...((options.headers as Record<string, string>) || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) message = data.error;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new ApiError(message, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return undefined as T;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return text as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { apiFetch, ApiError } from './client';
|
||||||
|
import {
|
||||||
|
Author,
|
||||||
|
ArchiveItem,
|
||||||
|
CommentView,
|
||||||
|
CreateCommentRequest,
|
||||||
|
CreatePostRequest,
|
||||||
|
FolderCount,
|
||||||
|
HotPost,
|
||||||
|
PageContent,
|
||||||
|
PostDetail,
|
||||||
|
PostSummary,
|
||||||
|
PreviewRequest,
|
||||||
|
PreviewResponse,
|
||||||
|
SaveDraftRequest,
|
||||||
|
TagCount,
|
||||||
|
UpdatePostRequest,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
async function fetcher<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
return apiFetch(`${API_URL}${path}`, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Public
|
||||||
|
health: () => fetcher<string>('/health'),
|
||||||
|
listPosts: (params?: { tag?: string; q?: string; folder?: string; page?: number }) => {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.tag) search.set('tag', params.tag);
|
||||||
|
if (params?.q) search.set('q', params.q);
|
||||||
|
if (params?.folder) search.set('folder', params.folder);
|
||||||
|
if (params?.page) search.set('page', String(params.page));
|
||||||
|
return fetcher<PostSummary[]>(`/posts?${search.toString()}`);
|
||||||
|
},
|
||||||
|
getPost: (slug: string) => fetcher<PostDetail>(`/posts/${slug}`),
|
||||||
|
listComments: (slug: string) => fetcher<CommentView[]>(`/posts/${slug}/comments`),
|
||||||
|
listTags: () => fetcher<TagCount[]>('/tags'),
|
||||||
|
listFolders: () => fetcher<FolderCount[]>('/folders'),
|
||||||
|
listHotPosts: () => fetcher<HotPost[]>('/posts/hot'),
|
||||||
|
getArchive: () => fetcher<ArchiveItem[]>('/archive'),
|
||||||
|
getPage: (slug: string) => fetcher<PageContent>(`/pages/${slug}`),
|
||||||
|
savePage: (slug: string, content_md: string) =>
|
||||||
|
fetcher<{ ok: boolean }>(`/admin/pages/${slug}`, { method: 'PUT', body: JSON.stringify({ content_md }) }),
|
||||||
|
uploadImage: async (file: File): Promise<{ url: string }> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await fetch(`${API_URL}/admin/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) message = data.error;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new ApiError(message, res.status);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
getRss: () => fetch(`${API_URL}/rss.xml`).then((r) => r.text()),
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
getMe: () => fetcher<{ role: string; user?: Author }>('/auth/me'),
|
||||||
|
|
||||||
|
// User
|
||||||
|
createComment: (slug: string, body: CreateCommentRequest) =>
|
||||||
|
fetcher<CommentView>(`/posts/${slug}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
adminListPosts: () => fetcher<PostSummary[]>('/admin/posts'),
|
||||||
|
getAdminPost: (id: string) => fetcher<PostDetail>(`/admin/posts/${id}`),
|
||||||
|
createPost: (body: CreatePostRequest) => fetcher<PostDetail>('/admin/posts', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
updatePost: (id: string, body: UpdatePostRequest) =>
|
||||||
|
fetcher<PostDetail>(`/admin/posts/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
deletePost: (id: string) => fetcher<{ ok: boolean }>(`/admin/posts/${id}`, { method: 'DELETE' }),
|
||||||
|
saveDraft: (id: string, body: SaveDraftRequest) =>
|
||||||
|
fetcher<{ ok: boolean }>(`/admin/posts/${id}/draft`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
getDraft: (id: string) => fetcher<{ content_md: string }>(`/admin/posts/${id}/draft`),
|
||||||
|
publishPost: (id: string) => fetcher<PostDetail>(`/admin/posts/${id}/publish`, { method: 'POST' }),
|
||||||
|
unpublishPost: (id: string) => fetcher<PostDetail>(`/admin/posts/${id}/unpublish`, { method: 'POST' }),
|
||||||
|
preview: (body: PreviewRequest) => fetcher<PreviewResponse>('/admin/preview', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
deleteComment: (id: string) => fetcher<{ ok: boolean }>(`/admin/comments/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export function AuthInit({ children }: { children: React.ReactNode }) {
|
||||||
|
const { token, setUser, logout } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setUser('guest', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.getMe()
|
||||||
|
.then((data) => {
|
||||||
|
setUser(data.role as 'guest' | 'user' | 'admin', data.user || null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
logout();
|
||||||
|
});
|
||||||
|
}, [token, setUser, logout]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Component, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局错误边界:渲染层报错时显示错误信息而不是白屏 */
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: unknown) {
|
||||||
|
console.error('render error:', error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8 text-center">
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-star">页面出错了</h1>
|
||||||
|
<pre className="max-w-xl overflow-x-auto rounded-lg bg-slate-100 p-4 text-left text-xs text-red-600 dark:bg-space-800 dark:text-red-400">
|
||||||
|
{this.state.error.message}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white dark:bg-nebula"
|
||||||
|
>
|
||||||
|
刷新重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Moon, Sun, Menu, X, Github, Sparkles } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useThemeStore } from '../store/theme';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { theme, toggle } = useThemeStore();
|
||||||
|
const { role, user, logout } = useAuthStore();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: '首页', to: '/' },
|
||||||
|
{ label: '文库', to: '/library' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (role === 'admin') {
|
||||||
|
navItems.push({ label: '后台', to: '/admin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed left-0 right-0 top-0 z-50 border-b border-stone-200/80 bg-white/80 backdrop-blur-md dark:border-indigo-500/10 dark:bg-space-900/80">
|
||||||
|
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
|
<Link to="/profile" className="flex items-center gap-2">
|
||||||
|
<Sparkles className="text-indigo-500" size={22} />
|
||||||
|
<span className="text-lg font-bold tracking-tight text-stone-900 dark:text-star">
|
||||||
|
zhaoxi的个人博客
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden items-center gap-8 md:flex">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link key={item.to} to={item.to} className="nav-link text-sm font-medium">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{role === 'guest' ? (
|
||||||
|
<a
|
||||||
|
href={`${import.meta.env.VITE_API_URL || '/api'}/auth/github?redirect=${encodeURIComponent(window.location.origin + '/oauth/callback')}`}
|
||||||
|
className="btn-press hidden items-center gap-2 rounded-full bg-stone-900 px-4 py-2 text-sm font-medium text-white transition-transform hover:scale-105 dark:bg-nebula dark:text-white md:flex"
|
||||||
|
>
|
||||||
|
<Github size={16} />
|
||||||
|
登录
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="hidden items-center gap-3 md:flex">
|
||||||
|
{user?.avatar_url ? (
|
||||||
|
<img src={user.avatar_url} alt={user.name} className="h-8 w-8 rounded-full ring-2 ring-indigo-500/30" />
|
||||||
|
) : null}
|
||||||
|
<span className="text-sm text-stone-600 dark:text-star-dim">{user?.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-sm text-stone-500 hover:text-stone-900 dark:text-star-dim dark:hover:text-star"
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="btn-press rounded-full p-2 text-stone-600 transition-colors hover:bg-stone-100 dark:text-star-dim dark:hover:bg-space-800"
|
||||||
|
aria-label="toggle theme"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
|
className="rounded-lg p-2 text-stone-600 hover:bg-stone-100 dark:text-star-dim dark:hover:bg-space-800 md:hidden"
|
||||||
|
>
|
||||||
|
{mobileOpen ? <X size={20} /> : <Menu size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mobileOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="border-t border-stone-200 bg-white px-4 py-4 dark:border-indigo-500/10 dark:bg-space-900 md:hidden"
|
||||||
|
>
|
||||||
|
<nav className="flex flex-col gap-3">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="text-base font-medium text-stone-700 dark:text-star"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{role === 'guest' && (
|
||||||
|
<a
|
||||||
|
href={`${import.meta.env.VITE_API_URL || '/api'}/auth/github?redirect=${encodeURIComponent(window.location.origin + '/oauth/callback')}`}
|
||||||
|
className="mt-2 flex items-center justify-center gap-2 rounded-full bg-stone-900 py-2 text-sm font-medium text-white dark:bg-nebula"
|
||||||
|
>
|
||||||
|
<Github size={16} />
|
||||||
|
GitHub 登录
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { isValidElement, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import rehypeHighlight from 'rehype-highlight';
|
||||||
|
import rehypeSlug from 'rehype-slug';
|
||||||
|
import rehypeKatex from 'rehype-katex';
|
||||||
|
|
||||||
|
/** 从 code 元素的 className(如 "language-python")中提取语言名 */
|
||||||
|
function extractLanguage(children: ReactNode): string {
|
||||||
|
if (isValidElement<{ className?: string }>(children)) {
|
||||||
|
const match = /language-(\w+)/.exec(children.props.className ?? '');
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自定义代码块:浅色底框 + 顶部语言标注栏 */
|
||||||
|
function Pre({ children }: ComponentPropsWithoutRef<'pre'>) {
|
||||||
|
const lang = extractLanguage(children);
|
||||||
|
return (
|
||||||
|
<div className="code-block-wrapper">
|
||||||
|
<div className="code-block-header">
|
||||||
|
<span>{lang}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="code-block-content">{children}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一的 Markdown 渲染组件,文章页与编辑器预览共用 */
|
||||||
|
export function Markdown({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
|
rehypePlugins={[rehypeKatex, rehypeHighlight, rehypeSlug]}
|
||||||
|
components={{ pre: Pre }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Calendar, Tag } from 'lucide-react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { PostSummary } from '../types';
|
||||||
|
|
||||||
|
interface PostCardProps {
|
||||||
|
post: PostSummary;
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostCard({ post, featured }: PostCardProps) {
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!cardRef.current) return;
|
||||||
|
const rect = cardRef.current.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||||
|
cardRef.current.style.setProperty('--mouse-x', `${x}%`);
|
||||||
|
cardRef.current.style.setProperty('--mouse-y', `${y}%`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
ref={cardRef}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
className={`card-glow glow-border group p-6 transition-all hover:shadow-lg dark:shadow-none ${
|
||||||
|
featured ? 'md:col-span-2 md:grid md:grid-cols-2 md:gap-8 md:p-8' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
{post.tags.slice(0, 3).map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag}
|
||||||
|
to={`/?tag=${encodeURIComponent(tag)}`}
|
||||||
|
className="tag-pill bg-indigo-50 text-indigo-600 transition-colors hover:bg-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
|
||||||
|
>
|
||||||
|
<Tag size={10} />
|
||||||
|
{tag}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Link to={`/posts/${post.slug}`}>
|
||||||
|
<h3 className="mb-3 font-serif text-xl font-bold text-stone-900 transition-colors group-hover:text-indigo-600 dark:text-star dark:group-hover:text-nebula-glow md:text-2xl">
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
{post.summary && (
|
||||||
|
<p className="mb-4 line-clamp-3 text-sm leading-relaxed text-stone-600 dark:text-star-dim">
|
||||||
|
{post.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-xs text-stone-500 dark:text-star-dim">
|
||||||
|
<Calendar size={12} />
|
||||||
|
<time dateTime={post.published_at}>
|
||||||
|
{post.published_at ? dayjs(post.published_at).format('YYYY-MM-DD') : '未发布'}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{featured && (
|
||||||
|
<div className="mt-6 hidden overflow-hidden rounded-xl md:mt-0 md:block">
|
||||||
|
{post.cover_url ? (
|
||||||
|
<img
|
||||||
|
src={post.cover_url}
|
||||||
|
alt={post.title}
|
||||||
|
className="h-full max-h-72 w-full rounded-xl object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500/10 to-purple-500/10 dark:from-indigo-500/20 dark:to-purple-500/20">
|
||||||
|
<div className="text-6xl font-black text-indigo-300/30 dark:text-indigo-400/20">Blog</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface StarBackgroundProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StarBackground({ children }: StarBackgroundProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen">
|
||||||
|
<div className="fixed inset-0 -z-10 bg-paper dark:bg-space-950">
|
||||||
|
<div className="absolute inset-0 dark:bg-space-gradient">
|
||||||
|
<div className="stars absolute inset-0 opacity-0 dark:opacity-100"></div>
|
||||||
|
<div className="nebula absolute inset-0 opacity-0 dark:opacity-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** 首页大标题内容,存于 pages 表(slug = home-hero,JSON 格式) */
|
||||||
|
export interface HeroContent {
|
||||||
|
title_pre: string;
|
||||||
|
title_highlight: string;
|
||||||
|
title_post: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultHero: HeroContent = {
|
||||||
|
title_pre: '写作,是为了',
|
||||||
|
title_highlight: '更好地',
|
||||||
|
title_post: '思考。',
|
||||||
|
subtitle: '记录代码、架构与生活中的灵感片段。每一篇文章都是一颗漂浮在数字星空中的星星。',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 解析 DB 里的 JSON,缺字段或解析失败时回落到默认值 */
|
||||||
|
export function parseHero(contentMd?: string): HeroContent {
|
||||||
|
if (!contentMd) return defaultHero;
|
||||||
|
try {
|
||||||
|
return { ...defaultHero, ...JSON.parse(contentMd) };
|
||||||
|
} catch {
|
||||||
|
return defaultHero;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** 个人名片信息,存于 pages 表(slug = profile-card,JSON 格式)。这里是默认值。 */
|
||||||
|
export interface ProfileContent {
|
||||||
|
name: string;
|
||||||
|
bio: string;
|
||||||
|
/** 头像 URL,留空则显示首字母占位头像 */
|
||||||
|
avatar_url: string;
|
||||||
|
github: string;
|
||||||
|
email: string;
|
||||||
|
/** 个人网站,留空则不显示 */
|
||||||
|
website: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultProfile: ProfileContent = {
|
||||||
|
name: 'zhaoxi',
|
||||||
|
bio: '全栈工程师,关注 Rust 与云原生',
|
||||||
|
avatar_url: '',
|
||||||
|
github: 'https://github.com/zhaoxi',
|
||||||
|
email: 'hi@example.com',
|
||||||
|
website: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 解析 DB 里的 JSON,缺字段或解析失败时回落到默认值 */
|
||||||
|
export function parseProfile(contentMd?: string): ProfileContent {
|
||||||
|
if (!contentMd) return defaultProfile;
|
||||||
|
try {
|
||||||
|
return { ...defaultProfile, ...JSON.parse(contentMd) };
|
||||||
|
} catch {
|
||||||
|
return defaultProfile;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
@apply scroll-smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-paper text-stone-900 antialiased font-serif;
|
||||||
|
font-feature-settings: 'cv11', 'ss01';
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark body {
|
||||||
|
@apply bg-space-950 text-star font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
@apply bg-indigo-500/30 text-indigo-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::selection {
|
||||||
|
@apply bg-nebula/40 text-star;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.stars {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,0.3), transparent),
|
||||||
|
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.2), transparent),
|
||||||
|
radial-gradient(1px 1px at 90px 40px, rgba(255,255,255,0.4), transparent),
|
||||||
|
radial-gradient(2px 2px at 160px 120px, rgba(255,255,255,0.2), transparent),
|
||||||
|
radial-gradient(1px 1px at 230px 80px, rgba(255,255,255,0.3), transparent),
|
||||||
|
radial-gradient(2px 2px at 300px 20px, rgba(255,255,255,0.2), transparent),
|
||||||
|
radial-gradient(1px 1px at 350px 150px, rgba(255,255,255,0.4), transparent);
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 400px 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 20% 30%, rgba(99,102,241,0.12), transparent 40%),
|
||||||
|
radial-gradient(ellipse at 80% 70%, rgba(139,92,246,0.08), transparent 40%),
|
||||||
|
radial-gradient(ellipse at 50% 50%, rgba(34,211,238,0.06), transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
@apply bg-white/70 backdrop-blur-md border border-white/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass {
|
||||||
|
@apply bg-space-800/60 border-indigo-500/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-text {
|
||||||
|
@apply text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 via-purple-400 to-cyan-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
@apply relative text-stone-600 hover:text-stone-900 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .nav-link {
|
||||||
|
@apply text-star-dim hover:text-star;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link::after {
|
||||||
|
content: '';
|
||||||
|
@apply absolute left-0 -bottom-1 w-0 h-0.5 bg-indigo-500 transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover::after {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片悬浮光晕 */
|
||||||
|
.card-glow {
|
||||||
|
@apply relative overflow-hidden rounded-lg border border-stone-200 bg-white shadow-sm transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card-glow {
|
||||||
|
@apply border-indigo-500/20 bg-space-800/60 shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glow::before {
|
||||||
|
content: '';
|
||||||
|
@apply absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none;
|
||||||
|
background: radial-gradient(600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(99,102,241,0.08), transparent 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card-glow::before {
|
||||||
|
background: radial-gradient(600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(129,140,248,0.12), transparent 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glow:hover::before {
|
||||||
|
@apply opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发光边框 */
|
||||||
|
.glow-border {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border::after {
|
||||||
|
content: '';
|
||||||
|
@apply absolute inset-0 rounded-2xl opacity-0 transition-opacity duration-300 pointer-events-none;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg, rgba(99,102,241,0.4), rgba(139,92,246,0.4), rgba(34,211,238,0.4));
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border:hover::after {
|
||||||
|
@apply opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题动画 */
|
||||||
|
.title-reveal {
|
||||||
|
@apply inline-block overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-reveal span {
|
||||||
|
@apply inline-block transform translate-y-full;
|
||||||
|
animation: titleUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes titleUp {
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 夜间模式正文调白(prose-invert 用的是 invert 变量,得在这里覆盖) */
|
||||||
|
.dark .prose {
|
||||||
|
--tw-prose-invert-body: #ffffff;
|
||||||
|
--tw-prose-invert-headings: #ffffff;
|
||||||
|
--tw-prose-invert-bold: #ffffff;
|
||||||
|
--tw-prose-invert-quotes: #ffffff;
|
||||||
|
--tw-prose-invert-counters: #e0e7ff;
|
||||||
|
--tw-prose-invert-bullets: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块:浅色底框 + 顶部语言标注栏(夜间为深空蓝) */
|
||||||
|
.prose .code-block-wrapper {
|
||||||
|
@apply relative my-6 overflow-hidden rounded-lg border border-stone-200 bg-[#f6f8fa];
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose .code-block-header {
|
||||||
|
@apply flex items-center justify-between border-b border-stone-200 bg-stone-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wider text-stone-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre.code-block-content {
|
||||||
|
@apply m-0 overflow-x-auto bg-transparent p-4 font-mono text-sm leading-relaxed text-stone-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hljs 主题自带底色和内边距,去掉以免和容器冲突 */
|
||||||
|
.prose pre.code-block-content .hljs {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 夜间:深空蓝底 + GitHub Dark 语法配色 */
|
||||||
|
.dark .prose .code-block-wrapper {
|
||||||
|
@apply border-indigo-500/20 bg-space-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose .code-block-header {
|
||||||
|
@apply border-indigo-500/10 bg-space-900 text-star-dim;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose pre.code-block-content {
|
||||||
|
@apply text-slate-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hljs { color: #c9d1d9; }
|
||||||
|
.dark .hljs-doctag,
|
||||||
|
.dark .hljs-keyword,
|
||||||
|
.dark .hljs-meta .hljs-keyword,
|
||||||
|
.dark .hljs-template-tag,
|
||||||
|
.dark .hljs-template-variable,
|
||||||
|
.dark .hljs-type,
|
||||||
|
.dark .hljs-variable.language_ { color: #ff7b72; }
|
||||||
|
.dark .hljs-title,
|
||||||
|
.dark .hljs-title.class_,
|
||||||
|
.dark .hljs-title.class_.inherited__,
|
||||||
|
.dark .hljs-title.function_ { color: #d2a8ff; }
|
||||||
|
.dark .hljs-attr,
|
||||||
|
.dark .hljs-attribute,
|
||||||
|
.dark .hljs-literal,
|
||||||
|
.dark .hljs-meta,
|
||||||
|
.dark .hljs-number,
|
||||||
|
.dark .hljs-operator,
|
||||||
|
.dark .hljs-variable,
|
||||||
|
.dark .hljs-selector-attr,
|
||||||
|
.dark .hljs-selector-class,
|
||||||
|
.dark .hljs-selector-id { color: #79c0ff; }
|
||||||
|
.dark .hljs-regexp,
|
||||||
|
.dark .hljs-string,
|
||||||
|
.dark .hljs-meta .hljs-string { color: #a5d6ff; }
|
||||||
|
.dark .hljs-built_in,
|
||||||
|
.dark .hljs-symbol { color: #ffa657; }
|
||||||
|
.dark .hljs-comment,
|
||||||
|
.dark .hljs-code,
|
||||||
|
.dark .hljs-formula { color: #8b949e; }
|
||||||
|
.dark .hljs-name,
|
||||||
|
.dark .hljs-quote,
|
||||||
|
.dark .hljs-selector-tag,
|
||||||
|
.dark .hljs-selector-pseudo { color: #7ee787; }
|
||||||
|
.dark .hljs-subst { color: #c9d1d9; }
|
||||||
|
.dark .hljs-section { color: #1f6feb; font-weight: bold; }
|
||||||
|
.dark .hljs-bullet { color: #f2cc60; }
|
||||||
|
.dark .hljs-emphasis { color: #c9d1d9; font-style: italic; }
|
||||||
|
.dark .hljs-strong { color: #c9d1d9; font-weight: bold; }
|
||||||
|
.dark .hljs-addition { color: #aff5b4; background-color: #033a16; }
|
||||||
|
.dark .hljs-deletion { color: #ffdcd7; background-color: #67060c; }
|
||||||
|
|
||||||
|
/* 阅读进度条 */
|
||||||
|
.progress-bar {
|
||||||
|
@apply fixed left-0 top-0 h-1 bg-gradient-to-r from-indigo-500 via-purple-500 to-cyan-500 transition-transform origin-left z-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TOC */
|
||||||
|
.toc-item {
|
||||||
|
@apply relative pl-4 text-sm text-stone-500 transition-colors hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-item::before {
|
||||||
|
content: '';
|
||||||
|
@apply absolute left-0 top-1/2 h-0 w-0.5 -translate-y-1/2 bg-indigo-500 transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-item.active::before {
|
||||||
|
@apply h-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-item.active {
|
||||||
|
@apply text-indigo-600 font-medium dark:text-nebula-glow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条美化 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply rounded-full bg-stone-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-space-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮按压效果 */
|
||||||
|
.btn-press {
|
||||||
|
@apply active:scale-95 transition-transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签云 */
|
||||||
|
.tag-pill {
|
||||||
|
@apply inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框聚焦光效 */
|
||||||
|
.input-glow {
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-glow:focus {
|
||||||
|
@apply shadow-[0_0_0_3px_rgba(99,102,241,0.15)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .input-glow:focus {
|
||||||
|
@apply shadow-[0_0_0_3px_rgba(129,140,248,0.2)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export interface FolderNode<T> {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
children: FolderNode<T>[];
|
||||||
|
items: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把带 folder 路径(如 "tech/rust")的扁平列表组装成目录树 */
|
||||||
|
export function buildFolderTree<T>(items: T[], getFolder: (item: T) => string): FolderNode<T> {
|
||||||
|
const root: FolderNode<T> = { name: '', path: '', children: [], items: [] };
|
||||||
|
for (const item of items) {
|
||||||
|
const parts = getFolder(item).split('/').filter(Boolean);
|
||||||
|
let node = root;
|
||||||
|
let path = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
path = path ? `${path}/${part}` : part;
|
||||||
|
let child = node.children.find((c) => c.name === part);
|
||||||
|
if (!child) {
|
||||||
|
child = { name: part, path, children: [], items: [] };
|
||||||
|
node.children.push(child);
|
||||||
|
}
|
||||||
|
node = child;
|
||||||
|
}
|
||||||
|
node.items.push(item);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归统计节点下的条目总数 */
|
||||||
|
export function countItems<T>(node: FolderNode<T>): number {
|
||||||
|
return node.items.length + node.children.reduce((sum, c) => sum + countItems(c), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import 'github-markdown-css/github-markdown-light.css'
|
||||||
|
import 'highlight.js/styles/github.css'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Rss, Flame, MessageCircle } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { PostCard } from '../components/PostCard';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { parseHero } from '../config/hero';
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const { data: posts, isLoading } = useQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: () => api.listPosts({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: hotPosts } = useQuery({
|
||||||
|
queryKey: ['hot-posts'],
|
||||||
|
queryFn: () => api.listHotPosts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: heroPage } = useQuery({
|
||||||
|
queryKey: ['page', 'home-hero'],
|
||||||
|
queryFn: () => api.getPage('home-hero'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hero = parseHero(heroPage?.content_md);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mx-auto max-w-6xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<section className="mb-16">
|
||||||
|
<h1 className="title-reveal mb-6 font-serif text-4xl font-bold tracking-tight text-stone-900 dark:text-star sm:text-5xl">
|
||||||
|
<span>{hero.title_pre}</span>
|
||||||
|
<span className="glow-text">{hero.title_highlight}</span>
|
||||||
|
<span>{hero.title_post}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-stone-600 dark:text-star-dim">
|
||||||
|
{hero.subtitle}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-12 lg:grid-cols-[1fr_280px]">
|
||||||
|
<div>
|
||||||
|
<div className="mb-10 flex justify-end">
|
||||||
|
<Link
|
||||||
|
to="/rss"
|
||||||
|
className="btn-press inline-flex items-center gap-2 rounded-full border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 transition-colors hover:border-indigo-500 hover:text-indigo-600 dark:border-indigo-500/20 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||||
|
>
|
||||||
|
<Rss size={16} />
|
||||||
|
RSS
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-48 animate-pulse rounded-2xl bg-stone-200 dark:bg-space-800"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{posts?.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} featured={!!post.cover_url} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && posts?.length === 0 && (
|
||||||
|
<div className="py-20 text-center text-stone-500 dark:text-star-dim">没有找到文章</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<div className="sticky top-24 space-y-8">
|
||||||
|
{hotPosts && hotPosts.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-stone-500 dark:text-star-dim">
|
||||||
|
<Flame size={14} className="text-orange-500" />
|
||||||
|
热门文章
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{hotPosts.map((post) => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link
|
||||||
|
to={`/posts/${post.slug}`}
|
||||||
|
className="group flex items-start justify-between gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-stone-700 transition-colors group-hover:text-indigo-600 dark:text-star-dim dark:group-hover:text-nebula-glow">
|
||||||
|
{post.title}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-1 text-xs text-stone-400 dark:text-star-dim">
|
||||||
|
<MessageCircle size={12} />
|
||||||
|
{post.comment_count}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Search, Folder, Tag } from 'lucide-react';
|
||||||
|
import { PostCard } from '../components/PostCard';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { FolderCount } from '../types';
|
||||||
|
import { buildFolderTree, countItems, FolderNode } from '../lib/tree';
|
||||||
|
|
||||||
|
/** 文库:目录树 + 标签 + 搜索,全站文章的统一查询入口 */
|
||||||
|
export function LibraryPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [inputQ, setInputQ] = useState(searchParams.get('q') || '');
|
||||||
|
|
||||||
|
const tag = searchParams.get('tag') || undefined;
|
||||||
|
const q = searchParams.get('q') || undefined;
|
||||||
|
const folder = searchParams.get('folder') || undefined;
|
||||||
|
|
||||||
|
const { data: posts, isLoading } = useQuery({
|
||||||
|
queryKey: ['posts', { tag, q, folder }],
|
||||||
|
queryFn: () => api.listPosts({ tag, q, folder }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: folders } = useQuery({
|
||||||
|
queryKey: ['folders'],
|
||||||
|
queryFn: () => api.listFolders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tags } = useQuery({
|
||||||
|
queryKey: ['tags'],
|
||||||
|
queryFn: () => api.listTags(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setFilter = (key: string, value?: string) => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (value) next.set(key, value);
|
||||||
|
else next.delete(key);
|
||||||
|
setSearchParams(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFilter('q', inputQ || undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mx-auto max-w-6xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<header className="mb-10">
|
||||||
|
<h1 className="mb-3 font-serif text-4xl font-bold tracking-tight text-stone-900 dark:text-star">
|
||||||
|
文库
|
||||||
|
</h1>
|
||||||
|
<p className="text-stone-600 dark:text-star-dim">按目录、标签或关键词检索全部文章。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-12 lg:grid-cols-[240px_1fr]">
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<div className="sticky top-24 space-y-8">
|
||||||
|
{folders && folders.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-stone-500 dark:text-star-dim">
|
||||||
|
<Folder size={14} className="text-indigo-500 dark:text-nebula-glow" />
|
||||||
|
目录
|
||||||
|
</h2>
|
||||||
|
<FolderTree folders={folders} active={folder} onSelect={(path) => setFilter('folder', path)} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tags && tags.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-stone-500 dark:text-star-dim">
|
||||||
|
<Tag size={14} className="text-indigo-500 dark:text-nebula-glow" />
|
||||||
|
标签
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.name}
|
||||||
|
onClick={() => setFilter('tag', tag === t.name ? undefined : t.name)}
|
||||||
|
className={`tag-pill border ${
|
||||||
|
tag === t.name
|
||||||
|
? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||||
|
: 'border-stone-200 text-stone-600 hover:border-indigo-500 hover:text-indigo-600 dark:border-indigo-500/20 dark:text-star-dim dark:hover:text-nebula-glow'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
<span className="text-xs text-stone-400">{t.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleSearch} className="relative mb-8 w-full max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputQ}
|
||||||
|
onChange={(e) => setInputQ(e.target.value)}
|
||||||
|
placeholder="搜索文章..."
|
||||||
|
className="input-glow w-full rounded-full border border-stone-200 bg-white py-2.5 pl-10 pr-4 text-sm text-stone-900 outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star dark:placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{(tag || folder || q) && (
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-4 text-sm text-stone-600 dark:text-star-dim">
|
||||||
|
{folder && <FilterChip label={`目录:${folder}`} onClear={() => setFilter('folder')} />}
|
||||||
|
{tag && <FilterChip label={`标签:${tag}`} onClear={() => setFilter('tag')} />}
|
||||||
|
{q && <FilterChip label={`搜索:${q}`} onClear={() => { setInputQ(''); setFilter('q'); }} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-48 animate-pulse rounded-2xl bg-stone-200 dark:bg-space-800"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{posts?.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} featured={!!post.cover_url} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && posts?.length === 0 && (
|
||||||
|
<div className="py-20 text-center text-stone-500 dark:text-star-dim">没有找到文章</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterChip({ label, onClear }: { label: string; onClear: () => void }) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="tag-pill bg-indigo-100 font-medium text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<button onClick={onClear} className="text-stone-400 hover:text-stone-600 dark:hover:text-star">
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderTree({
|
||||||
|
folders,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
folders: FolderCount[];
|
||||||
|
active?: string;
|
||||||
|
onSelect: (path?: string) => void;
|
||||||
|
}) {
|
||||||
|
const tree = useMemo(() => buildFolderTree(folders, (f) => f.name), [folders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(undefined)}
|
||||||
|
className={`w-full rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||||
|
!active
|
||||||
|
? 'bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||||
|
: 'text-stone-600 hover:bg-stone-100 dark:text-star-dim dark:hover:bg-space-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全部文章
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{tree.children.map((node) => (
|
||||||
|
<FolderTreeNode key={node.path} node={node} depth={0} active={active} onSelect={onSelect} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderTreeNode({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
node: FolderNode<FolderCount>;
|
||||||
|
depth: number;
|
||||||
|
active?: string;
|
||||||
|
onSelect: (path?: string) => void;
|
||||||
|
}) {
|
||||||
|
const isActive = active === node.path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(node.path)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||||
|
: 'text-stone-600 hover:bg-stone-100 dark:text-star-dim dark:hover:bg-space-800'
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${depth * 1 + 0.5}rem` }}
|
||||||
|
>
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
<span className="text-xs text-stone-400">{countItems(node)}</span>
|
||||||
|
</button>
|
||||||
|
{node.children.length > 0 && (
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<FolderTreeNode
|
||||||
|
key={child.path}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
active={active}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export function OAuthCallback() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setToken, setUser } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
const token = params.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
|
||||||
|
api
|
||||||
|
.getMe()
|
||||||
|
.then((data) => {
|
||||||
|
setUser(data.role as 'guest' | 'user' | 'admin', data.user || null);
|
||||||
|
if (data.role === 'admin') {
|
||||||
|
navigate('/admin');
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
navigate('/');
|
||||||
|
});
|
||||||
|
}, [setToken, setUser, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-indigo-500 border-t-transparent"></div>
|
||||||
|
<p className="text-slate-600 dark:text-star-dim">登录中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ArrowLeft, Calendar, Clock, Tag, MessageCircle, Send, Trash2 } from 'lucide-react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { Markdown } from '../components/Markdown';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { CommentView } from '../types';
|
||||||
|
|
||||||
|
export function PostPage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const { role } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const progressRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: post, isLoading } = useQuery({
|
||||||
|
queryKey: ['post', slug],
|
||||||
|
queryFn: () => api.getPost(slug!),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: comments } = useQuery({
|
||||||
|
queryKey: ['comments', slug],
|
||||||
|
queryFn: () => api.listComments(slug!),
|
||||||
|
enabled: !!slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createComment = useMutation({
|
||||||
|
mutationFn: () => api.createComment(slug!, { content: comment }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setComment('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['comments', slug] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteComment = useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteComment(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['comments', slug] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
contentRef.current.querySelectorAll('pre').forEach((pre) => {
|
||||||
|
if (!pre.querySelector('button.copy-btn')) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'copy-btn absolute right-2 top-2 rounded bg-slate-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-slate-600';
|
||||||
|
btn.textContent = '复制';
|
||||||
|
btn.onclick = () => {
|
||||||
|
const code = pre.querySelector('code')?.textContent || '';
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
btn.textContent = '已复制';
|
||||||
|
setTimeout(() => (btn.textContent = '复制'), 1500);
|
||||||
|
};
|
||||||
|
pre.classList.add('relative');
|
||||||
|
pre.appendChild(btn);
|
||||||
|
pre.onmouseenter = () => (btn.style.opacity = '1');
|
||||||
|
pre.onmouseleave = () => (btn.style.opacity = '0');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [post?.content_md]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!progressRef.current) return;
|
||||||
|
const winScroll = document.documentElement.scrollTop;
|
||||||
|
const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
||||||
|
const scrolled = height > 0 ? winScroll / height : 0;
|
||||||
|
progressRef.current.style.transform = `scaleX(${scrolled})`;
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) return <PostSkeleton />;
|
||||||
|
if (!post) return <div className="pt-32 text-center text-slate-500">文章不存在</div>;
|
||||||
|
|
||||||
|
const readTime = Math.max(1, Math.ceil(post.content_md.replace(/[#*`_\[\]\(\)\n]/g, '').length / 400));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={progressRef} className="progress-bar" />
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="mx-auto max-w-3xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="mb-8 inline-flex items-center gap-2 text-sm text-slate-500 hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> 返回首页
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="mb-10">
|
||||||
|
<h1 className="mb-6 text-3xl font-black leading-tight text-slate-900 dark:text-star sm:text-5xl">
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-500 dark:text-star-dim">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar size={14} />
|
||||||
|
{post.published_at ? dayjs(post.published_at).format('YYYY-MM-DD') : '未发布'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock size={14} />
|
||||||
|
{readTime} 分钟阅读
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag}
|
||||||
|
to={`/?tag=${encodeURIComponent(tag)}`}
|
||||||
|
className="tag-pill bg-slate-100 text-slate-600 transition-colors hover:bg-indigo-100 hover:text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
|
||||||
|
>
|
||||||
|
<Tag size={10} /> {tag}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article
|
||||||
|
ref={contentRef}
|
||||||
|
className="prose prose-lg max-w-none dark:prose-invert prose-headings:scroll-mt-24 prose-a:text-indigo-600 dark:prose-a:text-nebula-glow"
|
||||||
|
>
|
||||||
|
<Markdown content={post.content_md} />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section className="mt-16">
|
||||||
|
<h3 className="mb-6 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-star">
|
||||||
|
<MessageCircle size={20} /> 评论 ({comments?.length || 0})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{role !== 'guest' ? (
|
||||||
|
<div className="mb-8">
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="写下你的想法..."
|
||||||
|
rows={3}
|
||||||
|
className="input-glow w-full rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-900 outline-none transition-all focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => createComment.mutate()}
|
||||||
|
disabled={!comment.trim() || createComment.isPending}
|
||||||
|
className="btn-press inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2 text-sm font-medium text-white transition-all hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula dark:hover:bg-indigo-500"
|
||||||
|
>
|
||||||
|
<Send size={14} /> 发表评论
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mb-8 text-sm text-slate-500 dark:text-star-dim">
|
||||||
|
登录后可发表评论。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{comments?.map((c) => (
|
||||||
|
<CommentItem
|
||||||
|
key={c.id}
|
||||||
|
comment={c}
|
||||||
|
canDelete={role === 'admin'}
|
||||||
|
onDelete={() => deleteComment.mutate(c.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentItem({
|
||||||
|
comment,
|
||||||
|
canDelete,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
comment: CommentView;
|
||||||
|
canDelete: boolean;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-indigo-500/10 dark:bg-space-800/60">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{comment.author.avatar_url ? (
|
||||||
|
<img src={comment.author.avatar_url} alt="" className="h-6 w-6 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-indigo-100 text-xs font-bold text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300">
|
||||||
|
{comment.author.name[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-star">{comment.author.name}</span>
|
||||||
|
<span className="text-xs text-slate-400">{dayjs(comment.created_at).format('YYYY-MM-DD HH:mm')}</span>
|
||||||
|
</div>
|
||||||
|
{canDelete && (
|
||||||
|
<button onClick={onDelete} className="text-slate-400 hover:text-red-500">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-slate-700 dark:text-star-dim">{comment.content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 pb-20 pt-24">
|
||||||
|
<div className="mb-6 h-12 animate-pulse rounded bg-slate-200 dark:bg-space-800"></div>
|
||||||
|
<div className="mb-4 h-4 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-space-800"></div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-24 animate-pulse rounded bg-slate-200 dark:bg-space-800"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Github, Mail, Globe, FileText, Tag } from 'lucide-react';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { Markdown } from '../components/Markdown';
|
||||||
|
import { parseProfile } from '../config/profile';
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const { data: page, isLoading } = useQuery({
|
||||||
|
queryKey: ['page', 'profile'],
|
||||||
|
queryFn: () => api.getPage('profile'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: cardPage } = useQuery({
|
||||||
|
queryKey: ['page', 'profile-card'],
|
||||||
|
queryFn: () => api.getPage('profile-card'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: archive } = useQuery({
|
||||||
|
queryKey: ['archive'],
|
||||||
|
queryFn: () => api.getArchive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tags } = useQuery({
|
||||||
|
queryKey: ['tags'],
|
||||||
|
queryFn: () => api.listTags(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = parseProfile(cardPage?.content_md);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mx-auto max-w-5xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<div className="grid gap-10 lg:grid-cols-[300px_1fr]">
|
||||||
|
{/* 名片卡片 */}
|
||||||
|
<aside>
|
||||||
|
<div className="sticky top-24 rounded-2xl border border-stone-200 bg-white p-6 dark:border-indigo-500/20 dark:bg-space-800/60">
|
||||||
|
{profile.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={profile.avatar_url}
|
||||||
|
alt={profile.name}
|
||||||
|
className="mb-5 h-32 w-32 rounded-full object-cover ring-4 ring-indigo-500/20"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mb-5 flex h-32 w-32 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-5xl font-bold text-white ring-4 ring-indigo-500/20">
|
||||||
|
{profile.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-stone-900 dark:text-star">{profile.name}</h1>
|
||||||
|
<p className="mt-2 text-sm text-stone-600 dark:text-star-dim">{profile.bio}</p>
|
||||||
|
|
||||||
|
<div className="mt-5 flex gap-4 border-t border-stone-100 pt-5 text-sm text-stone-600 dark:border-indigo-500/10 dark:text-star-dim">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<FileText size={14} className="text-indigo-500 dark:text-nebula-glow" />
|
||||||
|
{archive?.length ?? 0} 文章
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Tag size={14} className="text-indigo-500 dark:text-nebula-glow" />
|
||||||
|
{tags?.length ?? 0} 标签
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-3 border-t border-stone-100 pt-5 dark:border-indigo-500/10">
|
||||||
|
{profile.github && (
|
||||||
|
<a
|
||||||
|
href={profile.github}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-3 text-sm text-stone-600 transition-colors hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||||
|
>
|
||||||
|
<Github size={16} />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{profile.email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${profile.email}`}
|
||||||
|
className="flex items-center gap-3 text-sm text-stone-600 transition-colors hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||||
|
>
|
||||||
|
<Mail size={16} />
|
||||||
|
{profile.email}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{profile.website && (
|
||||||
|
<a
|
||||||
|
href={profile.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-3 text-sm text-stone-600 transition-colors hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||||
|
>
|
||||||
|
<Globe size={16} />
|
||||||
|
{profile.website.replace(/^https?:\/\//, '')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 正文 */}
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-6 animate-pulse rounded bg-stone-200 dark:bg-space-800"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : page?.content_md ? (
|
||||||
|
<article className="prose prose-lg max-w-none dark:prose-invert">
|
||||||
|
<Markdown content={page.content_md} />
|
||||||
|
</article>
|
||||||
|
) : (
|
||||||
|
<p className="py-20 text-center text-stone-500 dark:text-star-dim">
|
||||||
|
还没有内容,去后台「个人主页」写点什么吧。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export function RssPage() {
|
||||||
|
const { data: rss, isLoading } = useQuery({
|
||||||
|
queryKey: ['rss'],
|
||||||
|
queryFn: () => api.getRss(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="mx-auto max-w-4xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="mb-2 flex items-center gap-3 text-3xl font-black text-slate-900 dark:text-star">
|
||||||
|
<FileText size={28} className="text-orange-500" /> RSS Feed
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600 dark:text-star-dim">你可以复制下方 XML 地址订阅更新。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-6 overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-indigo-500/20 dark:bg-space-800/60">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={`${import.meta.env.VITE_API_URL || '/api'}/rss.xml`}
|
||||||
|
className="w-full bg-transparent px-4 py-3 text-sm text-slate-700 outline-none dark:text-star-dim"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-64 animate-pulse rounded-xl bg-slate-200 dark:bg-space-800"></div>
|
||||||
|
) : (
|
||||||
|
<pre className="max-h-[600px] overflow-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-xs text-slate-700 dark:border-indigo-500/20 dark:bg-space-950 dark:text-star-dim">
|
||||||
|
{rss}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, Check, Loader2 } from 'lucide-react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { defaultHero, parseHero, type HeroContent } from '../../config/hero';
|
||||||
|
|
||||||
|
/** 后台"首页标题"编辑:主标题三段(中间段为渐变色)+ 副标题 */
|
||||||
|
export function AdminHeroPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<HeroContent>(defaultHero);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
const { data: page, isLoading } = useQuery({
|
||||||
|
queryKey: ['page', 'home-hero'],
|
||||||
|
queryFn: () => api.getPage('home-hero'),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized && page) {
|
||||||
|
setForm(parseHero(page.content_md));
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [page, initialized]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => api.savePage('home-hero', JSON.stringify(form)),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSavedAt(new Date());
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['page', 'home-hero'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const set = (key: keyof HeroContent) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
setForm({ ...form, [key]: e.target.value });
|
||||||
|
|
||||||
|
if (isLoading || !initialized) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-indigo-500" size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star';
|
||||||
|
const labelCls = 'mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="mx-auto max-w-3xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<header className="mb-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin')}
|
||||||
|
className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 dark:text-star-dim dark:hover:bg-space-800"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black text-slate-900 dark:text-star">首页标题</h1>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-star-dim">中间一段会以渐变色高亮显示</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 实时预览 */}
|
||||||
|
<div className="mb-8 rounded-2xl border border-slate-200 bg-white p-8 dark:border-indigo-500/20 dark:bg-space-800/60">
|
||||||
|
<h1 className="mb-4 font-serif text-4xl font-bold tracking-tight text-stone-900 dark:text-star">
|
||||||
|
<span>{form.title_pre}</span>
|
||||||
|
<span className="glow-text">{form.title_highlight}</span>
|
||||||
|
<span>{form.title_post}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-stone-600 dark:text-star-dim">{form.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>主标题(前段)</label>
|
||||||
|
<input value={form.title_pre} onChange={set('title_pre')} className={inputCls} placeholder="写作,是为了" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>主标题(高亮段)</label>
|
||||||
|
<input value={form.title_highlight} onChange={set('title_highlight')} className={inputCls} placeholder="更好地" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>主标题(后段)</label>
|
||||||
|
<input value={form.title_post} onChange={set('title_post')} className={inputCls} placeholder="思考。" />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<label className={labelCls}>副标题</label>
|
||||||
|
<textarea
|
||||||
|
value={form.subtitle}
|
||||||
|
onChange={set('subtitle')}
|
||||||
|
rows={2}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="一句话介绍这个博客"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{savedAt && (
|
||||||
|
<p className="mt-4 text-xs text-slate-500 dark:text-star-dim">已保存 {savedAt.toLocaleTimeString()}</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../store/auth';
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const { role } = useAuthStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (role !== 'admin') {
|
||||||
|
return <Navigate to="/" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FileText,
|
||||||
|
ExternalLink,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { PostSummary } from '../../types';
|
||||||
|
import { buildFolderTree, countItems, FolderNode } from '../../lib/tree';
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: posts, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-posts'],
|
||||||
|
queryFn: () => api.adminListPosts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = useMemo(() => buildFolderTree(posts ?? [], (p) => p.folder), [posts]);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.deletePost(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-posts'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="mx-auto max-w-4xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<header className="mb-8 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-black text-slate-900 dark:text-star">文章管理</h1>
|
||||||
|
<p className="text-slate-600 dark:text-star-dim">创建、编辑和发布你的文章</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/hero')}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:border-indigo-500 hover:text-indigo-600 dark:border-indigo-500/20 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||||
|
>
|
||||||
|
首页标题
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/profile')}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:border-indigo-500 hover:text-indigo-600 dark:border-indigo-500/20 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||||
|
>
|
||||||
|
个人主页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/new')}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition-transform hover:scale-105 dark:bg-nebula"
|
||||||
|
>
|
||||||
|
<Plus size={16} /> 新建文章
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-12 animate-pulse rounded-xl bg-slate-200 dark:bg-space-800"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 dark:border-indigo-500/20 dark:bg-space-800/60">
|
||||||
|
{tree.children.map((folder) => (
|
||||||
|
<FolderRow
|
||||||
|
key={folder.path}
|
||||||
|
node={folder}
|
||||||
|
depth={0}
|
||||||
|
onDelete={(id) => deleteMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{tree.items.map((post) => (
|
||||||
|
<PostRow key={post.id} post={post} depth={0} onDelete={() => deleteMutation.mutate(post.id)} />
|
||||||
|
))}
|
||||||
|
{posts?.length === 0 && (
|
||||||
|
<div className="py-20 text-center text-slate-500 dark:text-star-dim">
|
||||||
|
还没有文章,点击上方按钮创建第一篇。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderRow({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
node: FolderNode<PostSummary>;
|
||||||
|
depth: number;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-slate-50 dark:hover:bg-space-800/80"
|
||||||
|
style={{ paddingLeft: `${depth * 1.5 + 0.75}rem` }}
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown size={16} className="shrink-0 text-slate-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} className="shrink-0 text-slate-400" />
|
||||||
|
)}
|
||||||
|
{open ? (
|
||||||
|
<FolderOpen size={16} className="shrink-0 text-indigo-500 dark:text-nebula-glow" />
|
||||||
|
) : (
|
||||||
|
<Folder size={16} className="shrink-0 text-indigo-500 dark:text-nebula-glow" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-slate-800 dark:text-star">{node.name}</span>
|
||||||
|
<span className="text-xs text-slate-400 dark:text-star-dim">{countItems(node)} 篇</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<FolderRow key={child.path} node={child} depth={depth + 1} onDelete={onDelete} />
|
||||||
|
))}
|
||||||
|
{node.items.map((post) => (
|
||||||
|
<PostRow key={post.id} post={post} depth={depth + 1} onDelete={() => onDelete(post.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostRow({
|
||||||
|
post,
|
||||||
|
depth,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
post: PostSummary;
|
||||||
|
depth: number;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-space-800/80"
|
||||||
|
style={{ paddingLeft: `${depth * 1.5 + 2.75}rem` }}
|
||||||
|
>
|
||||||
|
<FileText size={16} className="shrink-0 text-slate-400" />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-star">
|
||||||
|
{post.title}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={post.status} />
|
||||||
|
<span className="hidden w-24 shrink-0 text-right text-xs text-slate-400 dark:text-star-dim sm:block">
|
||||||
|
{post.published_at ? dayjs(post.published_at).format('YYYY-MM-DD') : '-'}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
{post.status === 'public' && (
|
||||||
|
<Link
|
||||||
|
to={`/posts/${post.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-space-700 dark:hover:text-star"
|
||||||
|
>
|
||||||
|
<ExternalLink size={15} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/admin/edit/${post.id}`)}
|
||||||
|
className="rounded-lg p-1.5 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-500/10 dark:hover:text-nebula-glow"
|
||||||
|
>
|
||||||
|
<Edit2 size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="rounded-lg p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-500/10 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const config: Record<string, { icon: React.ReactNode; className: string; label: string }> = {
|
||||||
|
public: {
|
||||||
|
icon: <Eye size={12} />,
|
||||||
|
label: '已发布',
|
||||||
|
className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400',
|
||||||
|
},
|
||||||
|
private: {
|
||||||
|
icon: <EyeOff size={12} />,
|
||||||
|
label: '私有',
|
||||||
|
className: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
icon: <FileText size={12} />,
|
||||||
|
label: '草稿',
|
||||||
|
className: 'bg-slate-100 text-slate-700 dark:bg-space-700 dark:text-slate-300',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = config[status] || config.draft;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${c.className}`}
|
||||||
|
>
|
||||||
|
{c.icon}
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, Check, Loader2, ImagePlus, IdCard } from 'lucide-react';
|
||||||
|
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||||
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { Markdown } from '../../components/Markdown';
|
||||||
|
import { useThemeStore } from '../../store/theme';
|
||||||
|
import { defaultProfile, parseProfile, type ProfileContent } from '../../config/profile';
|
||||||
|
|
||||||
|
/** 后台"个人主页"编辑:名片信息(头像/联系方式)+ Markdown 正文 */
|
||||||
|
export function AdminProfilePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { theme } = useThemeStore();
|
||||||
|
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [card, setCard] = useState<ProfileContent>(defaultProfile);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
||||||
|
const [showCard, setShowCard] = useState(false);
|
||||||
|
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { data: page, isLoading } = useQuery({
|
||||||
|
queryKey: ['page', 'profile'],
|
||||||
|
queryFn: () => api.getPage('profile'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: cardPage } = useQuery({
|
||||||
|
queryKey: ['page', 'profile-card'],
|
||||||
|
queryFn: () => api.getPage('profile-card'),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized && page && cardPage) {
|
||||||
|
setContent(page.content_md || '# 关于我\n\n');
|
||||||
|
setCard(parseProfile(cardPage.content_md));
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [page, cardPage, initialized]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => api.savePage('profile', content),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSavedAt(new Date());
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['page', 'profile'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveCardMutation = useMutation({
|
||||||
|
mutationFn: () => api.savePage('profile-card', JSON.stringify(card)),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSavedAt(new Date());
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['page', 'profile-card'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => api.uploadImage(file),
|
||||||
|
onSuccess: ({ url }, file) => {
|
||||||
|
// 在光标处插入图片 Markdown,没有光标就追加到末尾
|
||||||
|
const view = editorRef.current?.view;
|
||||||
|
const snippet = ``;
|
||||||
|
if (view) {
|
||||||
|
const pos = view.state.selection.main.head;
|
||||||
|
view.dispatch({ changes: { from: pos, insert: snippet } });
|
||||||
|
} else {
|
||||||
|
setContent((c) => `${c}\n${snippet}\n`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e) => alert(`上传失败:${e.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatarUploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => api.uploadImage(file),
|
||||||
|
onSuccess: ({ url }) => setCard((c) => ({ ...c, avatar_url: url })),
|
||||||
|
onError: (e) => alert(`上传失败:${e.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !initialized) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-indigo-500" size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star';
|
||||||
|
const labelCls = 'mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim';
|
||||||
|
const setCardField = (key: keyof ProfileContent) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setCard({ ...card, [key]: e.target.value });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex h-screen flex-col pt-16">
|
||||||
|
<header className="flex items-center justify-between border-b border-slate-200 bg-white/90 px-4 py-3 backdrop-blur dark:border-indigo-500/10 dark:bg-space-900/90">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin')}
|
||||||
|
className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 dark:text-star-dim dark:hover:bg-space-800"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<span className="text-lg font-bold text-slate-900 dark:text-star">个人主页</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCard(!showCard)}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||||
|
showCard
|
||||||
|
? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:border-nebula dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||||
|
: 'border-slate-200 bg-white text-slate-600 hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<IdCard size={14} /> 名片信息
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) uploadMutation.mutate(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 transition-colors hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim"
|
||||||
|
>
|
||||||
|
{uploadMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <ImagePlus size={14} />}
|
||||||
|
上传图片
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
保存正文
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 名片信息面板 */}
|
||||||
|
{showCard && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
className="border-b border-slate-200 bg-slate-50 px-6 py-4 dark:border-indigo-500/10 dark:bg-space-900"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<label className={labelCls}>头像</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{card.avatar_url ? (
|
||||||
|
<img src={card.avatar_url} alt="avatar" className="h-12 w-12 rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-lg font-bold text-white">
|
||||||
|
{card.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) avatarUploadMutation.mutate(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
disabled={avatarUploadMutation.isPending}
|
||||||
|
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim"
|
||||||
|
>
|
||||||
|
{avatarUploadMutation.isPending ? '上传中...' : '上传头像'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={card.avatar_url}
|
||||||
|
onChange={setCardField('avatar_url')}
|
||||||
|
className={`${inputCls} mt-2`}
|
||||||
|
placeholder="或直接粘贴图片 URL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>名字</label>
|
||||||
|
<input value={card.name} onChange={setCardField('name')} className={inputCls} />
|
||||||
|
<label className={`${labelCls} mt-3`}>GitHub</label>
|
||||||
|
<input value={card.github} onChange={setCardField('github')} className={inputCls} placeholder="https://github.com/..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>一句话简介</label>
|
||||||
|
<input value={card.bio} onChange={setCardField('bio')} className={inputCls} />
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>邮箱</label>
|
||||||
|
<input value={card.email} onChange={setCardField('email')} className={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>个人网站</label>
|
||||||
|
<input value={card.website} onChange={setCardField('website')} className={inputCls} placeholder="留空不显示" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => saveCardMutation.mutate()}
|
||||||
|
disabled={saveCardMutation.isPending}
|
||||||
|
className="mt-4 inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula"
|
||||||
|
>
|
||||||
|
{saveCardMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
保存名片
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex flex-1 overflow-hidden">
|
||||||
|
<div className="flex w-1/2 flex-col border-r border-slate-200 dark:border-indigo-500/20">
|
||||||
|
<CodeMirror
|
||||||
|
ref={editorRef}
|
||||||
|
value={content}
|
||||||
|
height="100%"
|
||||||
|
theme={theme === 'dark' ? oneDark : 'light'}
|
||||||
|
extensions={[markdown()]}
|
||||||
|
onChange={(value) => setContent(value)}
|
||||||
|
className="flex-1 text-sm"
|
||||||
|
basicSetup={{ lineNumbers: true, highlightActiveLineGutter: true, highlightActiveLine: true, foldGutter: false }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/2 overflow-y-auto bg-slate-50 dark:bg-space-950">
|
||||||
|
<div className="prose prose-lg max-w-none p-8 dark:prose-invert">
|
||||||
|
<Markdown content={content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-between border-t border-slate-200 bg-white px-4 py-2 text-xs text-slate-500 dark:border-indigo-500/10 dark:bg-space-900 dark:text-star-dim">
|
||||||
|
<span>{content.length} 字符</span>
|
||||||
|
{savedAt && <span>已保存 {savedAt.toLocaleTimeString()}</span>}
|
||||||
|
</footer>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Eye,
|
||||||
|
Split,
|
||||||
|
FileEdit,
|
||||||
|
ArrowLeft,
|
||||||
|
Send,
|
||||||
|
Loader2,
|
||||||
|
Settings,
|
||||||
|
Check,
|
||||||
|
ImagePlus,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||||
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { useThemeStore } from '../../store/theme';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { PostDetail } from '../../types';
|
||||||
|
import { Markdown } from '../../components/Markdown';
|
||||||
|
|
||||||
|
type ViewMode = 'edit' | 'preview' | 'split';
|
||||||
|
|
||||||
|
export function EditorPage() {
|
||||||
|
const { id } = useParams<{ id?: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { theme } = useThemeStore();
|
||||||
|
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [summary, setSummary] = useState('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [folder, setFolder] = useState('');
|
||||||
|
const [contentMd, setContentMd] = useState('# 开始写作\n\n');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('split');
|
||||||
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showMeta, setShowMeta] = useState(false);
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
const { data: post, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-post', id],
|
||||||
|
queryFn: () => api.getAdminPost(id!),
|
||||||
|
enabled: isEdit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: draft } = useQuery({
|
||||||
|
queryKey: ['admin-draft', id],
|
||||||
|
queryFn: () => api.getDraft(id!),
|
||||||
|
enabled: isEdit,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized && (post || draft)) {
|
||||||
|
setTitle(post?.title || '');
|
||||||
|
setSlug(post?.slug || '');
|
||||||
|
setSummary(post?.summary || '');
|
||||||
|
setTags(post?.tags.join(', ') || '');
|
||||||
|
setFolder(post?.folder || '');
|
||||||
|
const md = draft?.content_md || post?.content_md || '# 开始写作\n\n';
|
||||||
|
setContentMd(md);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [post, draft, initialized]);
|
||||||
|
|
||||||
|
// 自动保存草稿
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit || !id || !initialized) return;
|
||||||
|
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
||||||
|
saveTimeout.current = setTimeout(() => {
|
||||||
|
handleSaveDraft();
|
||||||
|
}, 2000);
|
||||||
|
return () => {
|
||||||
|
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
||||||
|
};
|
||||||
|
}, [contentMd, id, isEdit, initialized]);
|
||||||
|
|
||||||
|
const createPostMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.createPost({
|
||||||
|
title: title || '无标题',
|
||||||
|
slug: slug || `post-${Date.now()}`,
|
||||||
|
summary: summary || undefined,
|
||||||
|
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
|
folder: folder || undefined,
|
||||||
|
content_md: contentMd,
|
||||||
|
}),
|
||||||
|
onSuccess: (data: PostDetail) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-posts'] });
|
||||||
|
navigate(`/admin/edit/${data.id}`, { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePostMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.updatePost(id!, {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
summary: summary || undefined,
|
||||||
|
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
|
folder,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-post', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-posts'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveDraftMutation = useMutation({
|
||||||
|
mutationFn: () => api.saveDraft(id!, { content_md: contentMd }),
|
||||||
|
onMutate: () => setSaving(true),
|
||||||
|
onSuccess: () => {
|
||||||
|
setLastSaved(new Date());
|
||||||
|
setSaving(false);
|
||||||
|
queryClient.setQueryData(['admin-draft', id], { content_md: contentMd });
|
||||||
|
},
|
||||||
|
onError: () => setSaving(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => api.uploadImage(file),
|
||||||
|
onSuccess: ({ url }, file) => {
|
||||||
|
// 在光标处插入图片 Markdown,没有光标就追加到末尾
|
||||||
|
const view = editorRef.current?.view;
|
||||||
|
const snippet = ``;
|
||||||
|
if (view) {
|
||||||
|
const pos = view.state.selection.main.head;
|
||||||
|
view.dispatch({ changes: { from: pos, insert: snippet } });
|
||||||
|
} else {
|
||||||
|
setContentMd((c) => `${c}\n${snippet}\n`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e) => alert(`上传失败:${e.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishMutation = useMutation({
|
||||||
|
mutationFn: () => api.publishPost(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-post', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-posts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-draft', id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unpublishMutation = useMutation({
|
||||||
|
mutationFn: () => api.unpublishPost(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-post', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-posts'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveDraft = () => {
|
||||||
|
if (id) saveDraftMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = () => {
|
||||||
|
if (!id) return;
|
||||||
|
saveDraftMutation.mutate(undefined, {
|
||||||
|
onSuccess: () => publishMutation.mutate(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && (isLoading || !initialized)) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-indigo-500" size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex h-screen flex-col pt-16"
|
||||||
|
>
|
||||||
|
{/* 顶部工具栏 */}
|
||||||
|
<header className="flex items-center justify-between border-b border-slate-200 bg-white/90 px-4 py-3 backdrop-blur dark:border-indigo-500/10 dark:bg-space-900/90">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin')}
|
||||||
|
className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 dark:text-star-dim dark:hover:bg-space-800"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="文章标题"
|
||||||
|
className="bg-transparent text-lg font-bold text-slate-900 outline-none placeholder:text-slate-400 dark:text-star"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="mr-2 hidden items-center gap-1 rounded-lg border border-slate-200 bg-slate-50 p-1 dark:border-indigo-500/20 dark:bg-space-800 md:flex">
|
||||||
|
{(['edit', 'split', 'preview'] as ViewMode[]).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => setViewMode(mode)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
viewMode === mode
|
||||||
|
? 'bg-white text-indigo-600 shadow-sm dark:bg-space-700 dark:text-nebula-glow'
|
||||||
|
: 'text-slate-500 hover:text-slate-700 dark:text-star-dim dark:hover:text-star'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mode === 'edit' && <FileEdit size={14} className="inline mr-1" />}
|
||||||
|
{mode === 'split' && <Split size={14} className="inline mr-1" />}
|
||||||
|
{mode === 'preview' && <Eye size={14} className="inline mr-1" />}
|
||||||
|
{mode === 'edit' && '编辑'}
|
||||||
|
{mode === 'split' && '分屏'}
|
||||||
|
{mode === 'preview' && '预览'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMeta(!showMeta)}
|
||||||
|
className={`rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||||
|
showMeta
|
||||||
|
? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:border-nebula dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||||
|
: 'border-slate-200 bg-white text-slate-600 hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings size={14} className="inline mr-1" /> 元信息
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) uploadMutation.mutate(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadMutation.isPending}
|
||||||
|
className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 transition-colors hover:border-indigo-500 disabled:opacity-50 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim"
|
||||||
|
>
|
||||||
|
{uploadMutation.isPending ? (
|
||||||
|
<Loader2 size={14} className="inline animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImagePlus size={14} className="inline" />
|
||||||
|
)}
|
||||||
|
传图
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isEdit ? (
|
||||||
|
<button
|
||||||
|
onClick={() => createPostMutation.mutate()}
|
||||||
|
disabled={createPostMutation.isPending}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula dark:hover:bg-indigo-500"
|
||||||
|
>
|
||||||
|
{createPostMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
创建文章
|
||||||
|
</button>
|
||||||
|
) : post?.status === 'public' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => unpublishMutation.mutate()}
|
||||||
|
disabled={unpublishMutation.isPending}
|
||||||
|
className="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim"
|
||||||
|
>
|
||||||
|
转私有
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={publishMutation.isPending}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula dark:hover:bg-indigo-500"
|
||||||
|
>
|
||||||
|
{publishMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||||
|
发布
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 元信息面板 */}
|
||||||
|
{showMeta && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
className="border-b border-slate-200 bg-slate-50 px-6 py-4 dark:border-indigo-500/10 dark:bg-space-900"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim">Slug</label>
|
||||||
|
<input
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||||
|
placeholder="post-slug"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim">目录(用 / 分层)</label>
|
||||||
|
<input
|
||||||
|
value={folder}
|
||||||
|
onChange={(e) => setFolder(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||||
|
placeholder="如 tech/rust,留空为根目录"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim">摘要</label>
|
||||||
|
<input
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||||
|
placeholder="简短描述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim">标签(逗号分隔)</label>
|
||||||
|
<input
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||||
|
placeholder="rust, web, kubernetes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => updatePostMutation.mutate()}
|
||||||
|
className="mt-4 inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 dark:bg-nebula"
|
||||||
|
>
|
||||||
|
<Check size={14} /> 保存元信息
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑器主体 */}
|
||||||
|
<div className="relative flex flex-1 overflow-hidden">
|
||||||
|
{(viewMode === 'edit' || viewMode === 'split') && (
|
||||||
|
<div className={`flex flex-col ${viewMode === 'split' ? 'w-1/2 border-r border-slate-200 dark:border-indigo-500/20' : 'w-full'}`}>
|
||||||
|
<CodeMirror
|
||||||
|
ref={editorRef}
|
||||||
|
value={contentMd}
|
||||||
|
height="100%"
|
||||||
|
theme={theme === 'dark' ? oneDark : 'light'}
|
||||||
|
extensions={[markdown()]}
|
||||||
|
onChange={(value) => setContentMd(value)}
|
||||||
|
className="flex-1 text-sm"
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
highlightActiveLineGutter: true,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
foldGutter: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(viewMode === 'preview' || viewMode === 'split') && (
|
||||||
|
<div
|
||||||
|
ref={previewRef}
|
||||||
|
className={`overflow-y-auto ${viewMode === 'split' ? 'w-1/2' : 'w-full'} bg-slate-50 dark:bg-space-950`}
|
||||||
|
>
|
||||||
|
<div className="prose prose-lg max-w-none p-8 dark:prose-invert prose-headings:scroll-mt-24"
|
||||||
|
>
|
||||||
|
<Markdown content={contentMd} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态栏 */}
|
||||||
|
<footer className="flex items-center justify-between border-t border-slate-200 bg-white px-4 py-2 text-xs text-slate-500 dark:border-indigo-500/10 dark:bg-space-900 dark:text-star-dim">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>{contentMd.length} 字符</span>
|
||||||
|
{post && (
|
||||||
|
<span className="rounded px-1.5 py-0.5 capitalize">
|
||||||
|
状态:
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
post.status === 'public'
|
||||||
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
: post.status === 'private'
|
||||||
|
? 'text-amber-600 dark:text-amber-400'
|
||||||
|
: 'text-slate-600 dark:text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{post.status}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{saving && <Loader2 size={12} className="animate-spin" />}
|
||||||
|
{lastSaved && !saving && (
|
||||||
|
<span>已保存 {lastSaved.toLocaleTimeString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { Author, UserRole } from '../types';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
user: Author | null;
|
||||||
|
setToken: (token: string | null) => void;
|
||||||
|
setUser: (role: UserRole, user?: Author | null) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
token: null,
|
||||||
|
role: 'guest',
|
||||||
|
user: null,
|
||||||
|
setToken: (token) => {
|
||||||
|
if (token) localStorage.setItem('token', token);
|
||||||
|
else localStorage.removeItem('token');
|
||||||
|
set({ token });
|
||||||
|
},
|
||||||
|
setUser: (role, user = null) => set({ role, user }),
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
set({ token: null, role: 'guest', user: null });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({ token: state.token, role: state.role, user: state.user }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
theme: Theme;
|
||||||
|
toggle: () => void;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialTheme(): Theme {
|
||||||
|
if (typeof window === 'undefined') return 'dark';
|
||||||
|
const saved = localStorage.getItem('theme') as Theme | null;
|
||||||
|
if (saved) return saved;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const forced = params.get('theme');
|
||||||
|
if (forced === 'dark' || forced === 'light') return forced;
|
||||||
|
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeStore = create<ThemeState>((set, get) => ({
|
||||||
|
theme: getInitialTheme(),
|
||||||
|
toggle: () => {
|
||||||
|
const next = get().theme === 'dark' ? 'light' : 'dark';
|
||||||
|
get().setTheme(next);
|
||||||
|
},
|
||||||
|
setTheme: (theme) => {
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
if (theme === 'dark') document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
set({ theme });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// apply on load
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (getInitialTheme() === 'dark') document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
export interface Author {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'guest' | 'user' | 'admin';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
github_id: number;
|
||||||
|
email?: string;
|
||||||
|
name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
role: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostStatus = 'draft' | 'private' | 'public';
|
||||||
|
|
||||||
|
export interface PostSummary {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
status: PostStatus;
|
||||||
|
published_at?: string;
|
||||||
|
tags: string[];
|
||||||
|
folder: string;
|
||||||
|
cover_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostDetail extends PostSummary {
|
||||||
|
content_md: string;
|
||||||
|
author: Author;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentView {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
author: Author;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagCount {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderCount {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotPost extends PostSummary {
|
||||||
|
comment_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveItem {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
folder: string;
|
||||||
|
published_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageContent {
|
||||||
|
slug: string;
|
||||||
|
content_md: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePostRequest {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
tags?: string[];
|
||||||
|
folder?: string;
|
||||||
|
content_md?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePostRequest {
|
||||||
|
slug?: string;
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
tags?: string[];
|
||||||
|
folder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveDraftRequest {
|
||||||
|
content_md: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommentRequest {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewRequest {
|
||||||
|
content_md: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewResponse {
|
||||||
|
content_html: string;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
space: {
|
||||||
|
950: '#050714',
|
||||||
|
900: '#0B1026',
|
||||||
|
800: '#111836',
|
||||||
|
700: '#1A2347',
|
||||||
|
},
|
||||||
|
nebula: {
|
||||||
|
DEFAULT: '#6366F1',
|
||||||
|
glow: '#818CF8',
|
||||||
|
dim: '#312E81',
|
||||||
|
},
|
||||||
|
star: {
|
||||||
|
DEFAULT: '#E0E7FF',
|
||||||
|
dim: '#94A3B8',
|
||||||
|
},
|
||||||
|
paper: {
|
||||||
|
DEFAULT: '#fafaf9',
|
||||||
|
dark: '#f5f5f4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
|
||||||
|
serif: ['Merriweather', 'Georgia', 'Noto Serif SC', 'Songti SC', 'serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
'twinkle': 'twinkle 3s ease-in-out infinite alternate',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
twinkle: {
|
||||||
|
'0%': { opacity: '0.3' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'space-gradient': 'radial-gradient(ellipse at top, #1e1b4b 0%, #0f172a 40%, #020617 100%)',
|
||||||
|
},
|
||||||
|
typography: (theme) => ({
|
||||||
|
DEFAULT: {
|
||||||
|
css: {
|
||||||
|
maxWidth: 'none',
|
||||||
|
'--tw-prose-headings': theme('colors.stone.900'),
|
||||||
|
'--tw-prose-links': theme('colors.blue.600'),
|
||||||
|
'--tw-prose-code': theme('colors.rose.500'),
|
||||||
|
'--tw-prose-pre-bg': theme('colors.stone.900'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
css: {
|
||||||
|
color: theme('colors.slate.300'),
|
||||||
|
'--tw-prose-headings': theme('colors.star.DEFAULT'),
|
||||||
|
'--tw-prose-links': theme('colors.nebula.glow'),
|
||||||
|
'--tw-prose-code': theme('colors.nebula.glow'),
|
||||||
|
'--tw-prose-pre-bg': theme('colors.space.800'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('@tailwindcss/typography')],
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"composite": true,
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "nodenext",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/index.ts","./src/components/AuthInit.tsx","./src/components/Header.tsx","./src/components/PostCard.tsx","./src/components/StarBackground.tsx","./src/pages/HomePage.tsx","./src/pages/OAuthCallback.tsx","./src/pages/PostPage.tsx","./src/pages/RssPage.tsx","./src/pages/TagsPage.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminPage.tsx","./src/pages/admin/EditorPage.tsx","./src/store/auth.ts","./src/store/theme.ts"],"version":"6.0.3"}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: blog
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: backend
|
||||||
|
spec:
|
||||||
|
initContainers:
|
||||||
|
- name: wait-for-postgres
|
||||||
|
image: busybox:1.36
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
until nc -z postgres 5432; do
|
||||||
|
echo "waiting for postgres..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
containers:
|
||||||
|
- name: backend
|
||||||
|
image: blog-backend:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
env:
|
||||||
|
- name: DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: database-url
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: jwt-secret
|
||||||
|
- name: GITHUB_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: github-client-id
|
||||||
|
- name: GITHUB_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: github-client-secret
|
||||||
|
- name: GITHUB_ADMIN_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: github-admin-id
|
||||||
|
- name: APP_URL
|
||||||
|
value: "https://your-domain.com"
|
||||||
|
- name: FRONTEND_URL
|
||||||
|
value: "https://your-domain.com"
|
||||||
|
# MOCK_OAUTH 生产环境绝不开启,缺省即关闭
|
||||||
|
- name: S3_ENDPOINT
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-endpoint
|
||||||
|
- name: S3_REGION
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-region
|
||||||
|
- name: S3_BUCKET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-bucket
|
||||||
|
- name: S3_ACCESS_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-access-key
|
||||||
|
- name: S3_SECRET_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-secret-key
|
||||||
|
- name: S3_PUBLIC_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-public-url
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: backend-secret
|
||||||
|
namespace: blog
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
database-url: "postgres://blog:change-me-in-production@postgres:5432/blog"
|
||||||
|
jwt-secret: "change-me-in-production"
|
||||||
|
github-client-id: "your-github-client-id"
|
||||||
|
github-client-secret: "your-github-client-secret"
|
||||||
|
github-admin-id: "your-github-user-id"
|
||||||
|
# S3 兼容对象存储,留空表示关闭上传功能
|
||||||
|
s3-endpoint: ""
|
||||||
|
s3-region: "us-east-1"
|
||||||
|
s3-bucket: ""
|
||||||
|
s3-access-key: ""
|
||||||
|
s3-secret-key: ""
|
||||||
|
s3-public-url: ""
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: blog
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: backend
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
targetPort: 3000
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: blog
|
||||||
|
namespace: blog
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: your-domain.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
# 前端静态文件由外部 Caddy 直接托管,集群内只暴露 API
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: backend
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: blog
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- postgres/secret.yaml
|
||||||
|
- postgres/statefulset.yaml
|
||||||
|
- postgres/service.yaml
|
||||||
|
# 可选:PostgreSQL 每日备份到 S3,填好 s3-* 后取消注释
|
||||||
|
# - postgres/backup-cronjob.yaml
|
||||||
|
- backend/secret.yaml
|
||||||
|
- backend/deployment.yaml
|
||||||
|
- backend/service.yaml
|
||||||
|
- ingress.yaml
|
||||||
|
|
||||||
|
images:
|
||||||
|
- name: blog-backend
|
||||||
|
newName: your-registry/blog-backend
|
||||||
|
newTag: latest
|
||||||
|
|
||||||
|
# 可选:统一修改域名
|
||||||
|
# replacements:
|
||||||
|
# - source:
|
||||||
|
# kind: Ingress
|
||||||
|
# name: blog
|
||||||
|
# targets: ...
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: blog
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# PostgreSQL 定时备份:每天凌晨 pg_dump 压缩后上传到 S3 兼容存储
|
||||||
|
# 需要先在 backend-secret 里填好 s3-* 配置,然后在 kustomization.yaml 里取消注释启用
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: postgres-backup
|
||||||
|
namespace: blog
|
||||||
|
spec:
|
||||||
|
# 每天 03:30 执行
|
||||||
|
schedule: "30 3 * * *"
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
successfulJobsHistoryLimit: 3
|
||||||
|
failedJobsHistoryLimit: 3
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: OnFailure
|
||||||
|
containers:
|
||||||
|
- name: backup
|
||||||
|
image: postgres:16-alpine
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
FILE="blog-$(date +%Y%m%d-%H%M%S).sql.gz"
|
||||||
|
pg_dump "$DATABASE_URL" | gzip > "/tmp/$FILE"
|
||||||
|
apk add --no-cache aws-cli > /dev/null
|
||||||
|
AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
|
||||||
|
aws --endpoint-url "$S3_ENDPOINT" s3 cp "/tmp/$FILE" "s3://$S3_BUCKET/backups/$FILE"
|
||||||
|
echo "backup uploaded: $FILE"
|
||||||
|
env:
|
||||||
|
- name: DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: database-url
|
||||||
|
- name: S3_ENDPOINT
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-endpoint
|
||||||
|
- name: S3_BUCKET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-bucket
|
||||||
|
- name: S3_ACCESS_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-access-key
|
||||||
|
- name: S3_SECRET_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: backend-secret
|
||||||
|
key: s3-secret-key
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: postgres-secret
|
||||||
|
namespace: blog
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
password: "change-me-in-production"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: blog
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: blog
|
||||||
|
spec:
|
||||||
|
serviceName: postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: blog
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: postgres-secret
|
||||||
|
key: password
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: blog
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
Reference in New Issue
Block a user