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