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:
2026-07-18 19:15:49 +08:00
commit bfc83a2400
92 changed files with 14221 additions and 0 deletions
+8
View File
@@ -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
View File
@@ -0,0 +1,10 @@
/target
/backend/target
.env
.idea/
*.swp
*.swo
.DS_Store
picture/
images/
+182
View File
@@ -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。
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
.git/
*.md
.env
+3629
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
+30
View File
@@ -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"]
+54
View File
@@ -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);
+2
View File
@@ -0,0 +1,2 @@
-- 文章目录:用 / 分隔的路径字符串(如 "tech/rust"),空串表示根目录
ALTER TABLE posts ADD COLUMN folder TEXT NOT NULL DEFAULT '';
+6
View File
@@ -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';
+131
View File
@@ -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))
}
+48
View File
@@ -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(),
})
}
}
+67
View File
@@ -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}"))
}
}
+80
View File
@@ -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(())
}
+23
View File
@@ -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,
})
}
+127
View File
@@ -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>>,
}
+395
View File
@@ -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()))?,
},
})
}
+206
View File
@@ -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,
},
})))
}
+20
View File
@@ -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())
}
+73
View File
@@ -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 })))
}
+294
View File
@@ -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())
}
+88
View File
@@ -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),
})))
}
+72
View File
@@ -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"),
},
}))
}
+9
View File
@@ -0,0 +1,9 @@
use sqlx::PgPool;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub pool: PgPool,
}
+44
View File
@@ -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:
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
.git
*.md
+7
View File
@@ -0,0 +1,7 @@
/node_modules
/dist
/.vite
.env
.env.local
*.log
.DS_Store
+8
View File
@@ -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 }]
}
}
+20
View File
@@ -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;"]
+10
View File
@@ -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"]
+32
View File
@@ -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.
+14
View File
@@ -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>
+22
View File
@@ -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;
}
}
+4790
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+13
View File
@@ -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

+24
View File
@@ -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

+21
View File
@@ -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');
})();
+184
View File
@@ -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);
}
}
+64
View File
@@ -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;
+46
View File
@@ -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';
}
}
+93
View File
@@ -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

+1
View File
@@ -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

+25
View File
@@ -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}</>;
}
+42
View File
@@ -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;
}
}
+112
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+24
View File
@@ -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;
}
}
+30
View File
@@ -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;
}
}
+287
View File
@@ -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)];
}
}
+32
View File
@@ -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);
}
+13
View File
@@ -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>,
)
+108
View File
@@ -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>
);
}
+230
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+224
View File
@@ -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>
);
}
+124
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+13
View File
@@ -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 />;
}
+227
View File
@@ -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 = `![${file.name}](${url})`;
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>
);
}
+413
View File
@@ -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 = `![${file.name}](${url})`;
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>
);
}
+36
View File
@@ -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 }),
},
),
);
+41
View File
@@ -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');
}
+102
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+69
View File
@@ -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')],
};
+26
View File
@@ -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"]
}
+21
View File
@@ -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" }]
}
+18
View File
@@ -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"]
}
+1
View File
@@ -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"}
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+19
View File
@@ -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,
},
});
+20
View File
@@ -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,
},
})
+105
View File
@@ -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
+19
View File
@@ -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: ""
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: blog
spec:
selector:
app: backend
ports:
- port: 3000
targetPort: 3000
+20
View File
@@ -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
+28
View File
@@ -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: ...
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: blog
+58
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
namespace: blog
type: Opaque
stringData:
password: "change-me-in-production"
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: blog
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
+42
View File
@@ -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