feat(auth): add loading state to login/register forms and update AGENTS.md

This commit is contained in:
xfy 2026-06-11 10:44:27 +08:00
parent f9d23d1eed
commit efa41b42c2
4 changed files with 133 additions and 77 deletions

168
AGENTS.md
View File

@ -3,25 +3,16 @@
## Development Commands
```bash
# Dev server — runs tailwindcss watch + dx serve (needs PostgreSQL running)
make dev
# Production build — builds editor, compiles CSS, then dx build --release
make build
# Just CSS
make css # one-shot
make css-watch # watch mode
# Rust only (no CSS/editor)
cargo build
cargo clippy
cargo test
dx check # Dioxus type-check
dx serve # Dev server without Tailwind watch
make dev # tailwindcss watch + dx serve (needs PostgreSQL)
make build # build-editor → highlight-css → tailwindcss → dx build --release
make build-linux # same as build but targets x86_64-unknown-linux-musl
make css # one-shot CSS
make css-watch # watch mode
make test # cargo test
make clean # cargo clean + rm public/style.css
```
**Command order matters for production**: `make build` internally runs `build-editor``tailwindcss` → `dx build --release`. Do not run `dx build --release` alone if you need the editor or CSS.
**Build order matters**: `make build` runs `build-editor``highlight-css` (`cargo run --bin generate_highlight_css`) → `tailwindcss --minify``dx build --release`. Do not run `dx build --release` alone.
## Prerequisites
@ -32,104 +23,139 @@ dx serve # Dev server without Tailwind watch
## Environment
Create `.env` (not committed — see `.env.example`):
Create `.env` (not committed):
```
DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil
RUST_LOG=info
```
Optional tuning via env vars (all have sane defaults):
```
WEBP_QUALITY=85.0 # 0.0100.0, clamped
WEBP_METHOD=2 # 06, clamped
RATE_LIMIT_STRICT_PER_SEC=1
RATE_LIMIT_STRICT_BURST=5
RATE_LIMIT_UPLOAD_PER_SEC=2
RATE_LIMIT_UPLOAD_BURST=15
RATE_LIMIT_IMAGE_PER_SEC=10
RATE_LIMIT_IMAGE_BURST=50
```
Run migrations before first dev server start:
```bash
./migrate.sh # preferred: auto-creates DB, runs all migrations in order
# or manually:
psql $DATABASE_URL -f migrations/001_init.sql
./migrate.sh # auto-creates DB, runs migrations/ in order
```
## Architecture: Conditional Compilation
This is a Dioxus 0.7 fullstack project with **two independent gates**. Getting this wrong is the most common source of compilation errors.
Dioxus 0.7 fullstack project with **two independent gates** the most common source of compilation errors.
| Gate | Applies to | Used for |
|------|-----------|----------|
| `#[cfg(feature = "server")]` | Server binary only | Database, env loading, background tasks, server functions body |
| `#[cfg(target_arch = "wasm32")]` | WASM frontend only | localStorage, DOM APIs, cookie manipulation, web_sys calls |
| `#[cfg(feature = "server")]` | Server binary only | DB, env loading, background tasks, server function bodies, highlight, WebP, caching |
| `#[cfg(target_arch = "wasm32")]` | WASM frontend only | localStorage, DOM APIs, web_sys calls, theme detection |
**Critical**: `feature = "server"` and `target_arch = "wasm32"` are **mutually exclusive** at build time, but both default features (`web` + `server`) are enabled in `Cargo.toml`. The `dx` CLI handles feature selection during builds.
**Critical**: Both default features (`web` + `server`) are enabled in `Cargo.toml`. The `dx` CLI handles feature selection during builds.
**Stub pattern**: `src/db/mod.rs` provides a `DummyPool` when the `server` feature is disabled so the crate still compiles for WASM. Do not remove this.
**Stub pattern**: `src/db/mod.rs` provides a `DummyPool` when `server` feature is disabled — do not remove.
**Dead code allowances**: Auth utilities (`password.rs`, `session.rs`) carry `#[allow(dead_code)]` because they are imported by `api/auth.rs` but the compiler sees them as unused in WASM builds where server function bodies are stripped.
**Dead code allowances**: `src/auth/password.rs` and `src/auth/session.rs` carry `#[allow(dead_code)]` because the compiler sees them as unused in WASM builds where server function bodies are stripped.
## Server Functions
## Dual API Architecture
Server functions live in `src/api/auth.rs` and use the macro:
The server exposes two distinct API patterns:
1. **Dioxus server functions** (`#[server(Name, "/api")]` in `src/api/`) — auto-routed, callable from both client and server Rust. Spread across `src/api/auth.rs` and `src/api/posts/`.
2. **Axum routes** (registered in `src/main.rs`) — manual `axum::Router` for endpoints that don't fit the server-function model:
- `POST /api/upload` — image upload (multipart, auth-required, rate-limited)
- `GET /uploads/{*path}` — image serving with on-the-fly resize/rotate/convert (query params: `w`, `h`, `thumb`, `rotate`, `format`, `quality`)
## Server Module Structure
```rust
#[server(Name, "/api")]
pub async fn fn_name(...) -> Result<..., ServerFnError>
```
These are callable from both client and server Rust code; Dioxus handles HTTP transport automatically.
src/api/ — server functions + Axum handlers
auth.rs — login, register, session validation
markdown.rs — Markdown→HTML rendering (pulldown-cmark + ammonia sanitization)
image.rs — image serving with processing pipeline + disk+memory cache
upload.rs — image upload, auto-converts to WebP
rate_limit.rs — governor-based rate limiting (3 tiers: strict/upload/image)
slug.rs — URL slug generation
posts/ — CRUD server functions for blog posts
src/auth/ — password hashing (Argon2) + session token management
src/bin/ — generate_highlight_css (build-time CSS generation)
src/cache.rs — moka future-based caches for posts, tags, stats
src/components/ — Dioxus UI components
src/db/ — PostgreSQL pool (deadpool-postgres, LazyLock global)
src/hooks/ — shared Dioxus hooks
src/models/ — Post, User, Tag data models
src/pages/ — route page components (frontend + admin)
src/tasks/ — background tokio tasks (session cleanup)
src/theme.rs — light/dark theme with SSR cookie + WASM localStorage
src/webp.rs — zenwebp encode/decode (image crate has no WebP)
```
## Tiptap Editor Subproject
The rich-text editor is a separate Vite project in `libs/tiptap-editor/`.
Rich-text editor in `libs/tiptap-editor/`, built as an IIFE library exposing `window.TiptapEditor`.
- Built as an **IIFE** library exposing `window.TiptapEditor`
- Output goes to `public/tiptap/`
- `make build` runs `npm install && vite build` inside `libs/tiptap-editor` and renames the output file
- The write page (`src/pages/admin/write.rs`) initializes the editor via `js_sys::eval` and polls `window.__tiptap_ready`
- Output: `public/tiptap/`
- `make build` runs `npm install && npx vite build` inside `libs/tiptap-editor`
- `src/pages/admin/write.rs` initializes via `js_sys::eval`, polls `window.__tiptap_ready`
Do not edit files in `public/tiptap/` directly — they are build artifacts.
Do not edit `public/tiptap/` — they are build artifacts.
## Syntax Highlighting Pipeline
- `themes/` contains Catppuccin Latte (light) and Mocha (dark) `.tmTheme` files
- `syntaxes/` has custom Sublime syntax definitions (Kotlin, Swift)
- `src/bin/generate_highlight_css.rs` generates `public/highlight.css` with class-based rules scoped under `.md-content pre code`, with `.dark` prefix for dark mode
- `src/highlight.rs` uses syntect at runtime for code block highlighting
- All gated behind `#[cfg(feature = "server")]`
## Auth & Session
- **Registration**: first user becomes `admin`; subsequent registrations are rejected with `"Registration is closed"`.
- **Login**: sets an **HttpOnly** cookie via `FullstackContext::add_response_header` (server-side).
- **Session validation**: `get_current_user` reads the `session` cookie from the request headers and queries `sessions` + `users` tables.
- **Background cleanup**: `tasks::session_cleanup::run_cleanup()` deletes expired sessions every hour on a tokio task.
- **Registration**: first user becomes `admin`; subsequent registrations rejected with `"Registration is closed"`
- **Login**: sets an HttpOnly cookie via `FullstackContext::add_response_header`
- **Session validation**: `get_current_user` reads `session` cookie, queries `sessions` + `users` tables
- **Background cleanup**: `tasks::session_cleanup::run_cleanup()` deletes expired sessions every hour
## Database
## Caching
- PostgreSQL via `tokio-postgres` + `deadpool-postgres`
- Pool is a `LazyLock` global in `src/db/pool.rs`, initialized from `DATABASE_URL`
- Max pool size: 10
- **Post/tag caches** (`src/cache.rs`): moka future-based, TTL varies by data type (60s600s). Invalidated on writes.
- **Image processing cache** (`src/api/image.rs`): two-tier — in-memory moka cache + disk cache in `uploads/.cache/`. Keyed by path + query params.
## Testing & Verification
## Testing
```bash
# Standard Rust test suite
cargo test
# Dioxus type-check (catches component/Router issues)
dx check
# Lint
cargo clippy
cargo test # standard Rust test suite
dx check # Dioxus type-check (catches component/Router issues)
cargo clippy # lint
```
There are currently no integration tests requiring a database connection.
Most tests use `#[cfg(all(test, feature = "server"))]` — they only run when the server feature is active (which is the default). No integration tests requiring a database connection.
## Build Artifacts to Ignore
## Image Processing Constraints
The following are generated and gitignored:
- The `image` crate is configured **without** WebP support (`default-features = false, features = ["jpeg", "png", "gif"]`). Do not add WebP to the image crate features.
- All WebP encode/decode goes through `zenwebp` via `src/webp.rs`.
- Upload pipeline auto-converts non-GIF/non-WebP images to WebP, keeping original format if WebP is larger.
- Image serving supports on-the-fly resize (`w`, `h`), thumbnail (`thumb=WxH`), rotation (90/180/270), and format conversion.
- `public/style.css` — generated by Tailwind
- `public/tiptap/` — generated by Vite from `libs/tiptap-editor/`
## Build Artifacts (gitignored)
- `public/style.css` — Tailwind output
- `public/highlight.css` — generated by `generate_highlight_css` binary
- `public/tiptap/` — Vite build output
- `/dist`, `/.dioxus`, `/target`
- `node_modules` (inside `libs/tiptap-editor/`)
## rust-analyzer
`rust-analyzer.toml` excludes generated directories (`node_modules`, `public`, `.dioxus`, `.omc`, `dist`). If rust-analyzer shows spurious errors in generated files, check that the exclusion list is respected by your editor.
## Image Processing
- **WebP handling**: The `image` crate is configured without WebP support (`default-features = false, features = ["jpeg", "png", "gif"]`). All WebP encode/decode operations go through `zenwebp` via `src/webp.rs`. This ensures consistent WebP handling across the codebase. Do not add WebP to the image crate features.
- `uploads/.cache/` — image processing disk cache
## Notes
- `rand` + `getrandom` with `js` feature are required for Argon2 salt generation in WASM builds.
- The `#[allow(unused_mut, unused_variables)]` on `Write` component is intentional — the `mut` signals are used inside `#[cfg(target_arch = "wasm32")]` blocks that are stripped in server builds.
- `#[allow(unused_mut, unused_variables)]` on `Write` component is intentional — `mut` signals are used in `#[cfg(target_arch = "wasm32")]` blocks stripped in server builds.
- Server uses incremental rendering with 300s cache (`IncrementalRendererConfig` in `src/main.rs`).

View File

@ -9,15 +9,18 @@ pub fn FormInput(
r#type: &'static str,
placeholder: &'static str,
value: String,
disabled: bool,
oninput: EventHandler<String>,
onkeydown: Option<EventHandler<KeyboardEvent>>,
) -> Element {
let disabled_class = if disabled { "opacity-60 cursor-not-allowed" } else { "" };
rsx! {
input {
class: "{INPUT_CLASS}",
class: "{INPUT_CLASS} {disabled_class}",
r#type: "{r#type}",
placeholder: "{placeholder}",
value: "{value}",
disabled,
oninput: move |e| oninput.call(e.value()),
onkeydown: move |e| {
if let Some(ref handler) = onkeydown {

View File

@ -11,10 +11,15 @@ pub fn Login() -> Element {
let mut username = use_signal(|| "".to_string());
let mut password = use_signal(|| "".to_string());
let mut error = use_signal(|| None::<String>);
let mut loading = use_signal(|| false);
let mut ctx: UserContext = use_context();
let on_submit = Callback::new(move |_| {
if loading() {
return;
}
error.set(None);
loading.set(true);
let username_val = username();
let password_val = password();
@ -47,9 +52,12 @@ pub fn Login() -> Element {
error.set(Some(format!("请求失败: {}", e)));
}
}
loading.set(false);
});
});
let is_loading = loading();
rsx! {
div { class: "min-h-screen flex items-center justify-center bg-paper-theme",
div { class: "w-full max-w-md p-8 bg-paper-entry rounded-2xl border border-paper-border shadow-sm",
@ -68,6 +76,7 @@ pub fn Login() -> Element {
r#type: "text",
placeholder: "用户名或邮箱",
value: username(),
disabled: is_loading,
oninput: move |v: String| username.set(v),
onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })),
}
@ -78,14 +87,17 @@ pub fn Login() -> Element {
r#type: "password",
placeholder: "密码",
value: password(),
disabled: is_loading,
oninput: move |v: String| password.set(v),
onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })),
}
}
button {
class: "{BUTTON_PRIMARY_CLASS}",
class: if is_loading { "opacity-60 cursor-not-allowed" },
disabled: is_loading,
onclick: move |_| on_submit(()),
"登录"
if is_loading { "登录中..." } else { "登录" }
}
Link {
class: "block w-full py-2 px-4 text-center text-paper-secondary hover:text-paper-accent font-medium rounded-lg transition-all duration-200 cursor-pointer",

View File

@ -13,8 +13,12 @@ pub fn Register() -> Element {
let mut confirm_password = use_signal(|| "".to_string());
let mut error = use_signal(|| None::<String>);
let mut success = use_signal(|| false);
let mut loading = use_signal(|| false);
let on_submit = move |_| {
let on_submit = Callback::new(move |_| {
if loading() {
return;
}
error.set(None);
success.set(false);
@ -31,6 +35,8 @@ pub fn Register() -> Element {
let email_val = email();
let password_val = password();
loading.set(true);
spawn(async move {
match register(username_val, email_val, password_val).await {
Ok(AuthResponse { success: true, .. }) => {
@ -47,8 +53,11 @@ pub fn Register() -> Element {
error.set(Some(format!("请求失败: {}", e)));
}
}
loading.set(false);
});
};
});
let is_loading = loading();
rsx! {
div { class: "min-h-screen flex items-center justify-center bg-paper-theme",
@ -81,6 +90,7 @@ pub fn Register() -> Element {
r#type: "text",
placeholder: "3-50 位字符",
value: username(),
disabled: is_loading,
oninput: move |v: String| username.set(v),
onkeydown: None,
}
@ -91,6 +101,7 @@ pub fn Register() -> Element {
r#type: "email",
placeholder: "your@email.com",
value: email(),
disabled: is_loading,
oninput: move |v: String| email.set(v),
onkeydown: None,
}
@ -101,6 +112,7 @@ pub fn Register() -> Element {
r#type: "password",
placeholder: "至少 8 位",
value: password(),
disabled: is_loading,
oninput: move |v: String| password.set(v),
onkeydown: None,
}
@ -111,14 +123,17 @@ pub fn Register() -> Element {
r#type: "password",
placeholder: "再次输入密码",
value: confirm_password(),
disabled: is_loading,
oninput: move |v: String| confirm_password.set(v),
onkeydown: None,
}
}
button {
class: "{BUTTON_PRIMARY_CLASS}",
onclick: on_submit,
"注册"
class: if is_loading { "opacity-60 cursor-not-allowed" },
disabled: is_loading,
onclick: move |_| on_submit(()),
if is_loading { "注册中..." } else { "注册" }
}
}
p { class: "mt-4 text-center text-sm text-paper-secondary",