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

166
AGENTS.md
View File

@ -3,25 +3,16 @@
## Development Commands ## Development Commands
```bash ```bash
# Dev server — runs tailwindcss watch + dx serve (needs PostgreSQL running) make dev # tailwindcss watch + dx serve (needs PostgreSQL)
make dev make build # build-editor → highlight-css → tailwindcss → dx build --release
make build-linux # same as build but targets x86_64-unknown-linux-musl
# Production build — builds editor, compiles CSS, then dx build --release make css # one-shot CSS
make build
# Just CSS
make css # one-shot
make css-watch # watch mode make css-watch # watch mode
make test # cargo test
# Rust only (no CSS/editor) make clean # cargo clean + rm public/style.css
cargo build
cargo clippy
cargo test
dx check # Dioxus type-check
dx serve # Dev server without Tailwind watch
``` ```
**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 ## Prerequisites
@ -32,104 +23,139 @@ dx serve # Dev server without Tailwind watch
## Environment ## Environment
Create `.env` (not committed — see `.env.example`): Create `.env` (not committed):
``` ```
DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil
RUST_LOG=info 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: Run migrations before first dev server start:
```bash ```bash
./migrate.sh # preferred: auto-creates DB, runs all migrations in order ./migrate.sh # auto-creates DB, runs migrations/ in order
# or manually:
psql $DATABASE_URL -f migrations/001_init.sql
``` ```
## Architecture: Conditional Compilation ## 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 | | Gate | Applies to | Used for |
|------|-----------|----------| |------|-----------|----------|
| `#[cfg(feature = "server")]` | Server binary only | Database, env loading, background tasks, server functions body | | `#[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, cookie manipulation, web_sys calls | | `#[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>
``` ```
src/api/ — server functions + Axum handlers
These are callable from both client and server Rust code; Dioxus handles HTTP transport automatically. 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 ## 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: `public/tiptap/`
- Output goes to `public/tiptap/` - `make build` runs `npm install && npx vite build` inside `libs/tiptap-editor`
- `make build` runs `npm install && vite build` inside `libs/tiptap-editor` and renames the output file - `src/pages/admin/write.rs` initializes via `js_sys::eval`, polls `window.__tiptap_ready`
- The write page (`src/pages/admin/write.rs`) initializes the editor via `js_sys::eval` and 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 ## Auth & Session
- **Registration**: first user becomes `admin`; subsequent registrations are rejected with `"Registration is closed"`. - **Registration**: first user becomes `admin`; subsequent registrations rejected with `"Registration is closed"`
- **Login**: sets an **HttpOnly** cookie via `FullstackContext::add_response_header` (server-side). - **Login**: sets an HttpOnly cookie via `FullstackContext::add_response_header`
- **Session validation**: `get_current_user` reads the `session` cookie from the request headers and queries `sessions` + `users` tables. - **Session validation**: `get_current_user` reads `session` cookie, queries `sessions` + `users` tables
- **Background cleanup**: `tasks::session_cleanup::run_cleanup()` deletes expired sessions every hour on a tokio task. - **Background cleanup**: `tasks::session_cleanup::run_cleanup()` deletes expired sessions every hour
## Database ## Caching
- PostgreSQL via `tokio-postgres` + `deadpool-postgres` - **Post/tag caches** (`src/cache.rs`): moka future-based, TTL varies by data type (60s600s). Invalidated on writes.
- Pool is a `LazyLock` global in `src/db/pool.rs`, initialized from `DATABASE_URL` - **Image processing cache** (`src/api/image.rs`): two-tier — in-memory moka cache + disk cache in `uploads/.cache/`. Keyed by path + query params.
- Max pool size: 10
## Testing & Verification ## Testing
```bash ```bash
# Standard Rust test suite cargo test # standard Rust test suite
cargo test dx check # Dioxus type-check (catches component/Router issues)
cargo clippy # lint
# Dioxus type-check (catches component/Router issues)
dx check
# Lint
cargo clippy
``` ```
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 ## Build Artifacts (gitignored)
- `public/tiptap/` — generated by Vite from `libs/tiptap-editor/`
- `public/style.css` — Tailwind output
- `public/highlight.css` — generated by `generate_highlight_css` binary
- `public/tiptap/` — Vite build output
- `/dist`, `/.dioxus`, `/target` - `/dist`, `/.dioxus`, `/target`
- `node_modules` (inside `libs/tiptap-editor/`) - `node_modules` (inside `libs/tiptap-editor/`)
- `uploads/.cache/` — image processing disk cache
## 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.
## Notes ## Notes
- `rand` + `getrandom` with `js` feature are required for Argon2 salt generation in WASM builds. - `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, r#type: &'static str,
placeholder: &'static str, placeholder: &'static str,
value: String, value: String,
disabled: bool,
oninput: EventHandler<String>, oninput: EventHandler<String>,
onkeydown: Option<EventHandler<KeyboardEvent>>, onkeydown: Option<EventHandler<KeyboardEvent>>,
) -> Element { ) -> Element {
let disabled_class = if disabled { "opacity-60 cursor-not-allowed" } else { "" };
rsx! { rsx! {
input { input {
class: "{INPUT_CLASS}", class: "{INPUT_CLASS} {disabled_class}",
r#type: "{r#type}", r#type: "{r#type}",
placeholder: "{placeholder}", placeholder: "{placeholder}",
value: "{value}", value: "{value}",
disabled,
oninput: move |e| oninput.call(e.value()), oninput: move |e| oninput.call(e.value()),
onkeydown: move |e| { onkeydown: move |e| {
if let Some(ref handler) = onkeydown { 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 username = use_signal(|| "".to_string());
let mut password = use_signal(|| "".to_string()); let mut password = use_signal(|| "".to_string());
let mut error = use_signal(|| None::<String>); let mut error = use_signal(|| None::<String>);
let mut loading = use_signal(|| false);
let mut ctx: UserContext = use_context(); let mut ctx: UserContext = use_context();
let on_submit = Callback::new(move |_| { let on_submit = Callback::new(move |_| {
if loading() {
return;
}
error.set(None); error.set(None);
loading.set(true);
let username_val = username(); let username_val = username();
let password_val = password(); let password_val = password();
@ -47,9 +52,12 @@ pub fn Login() -> Element {
error.set(Some(format!("请求失败: {}", e))); error.set(Some(format!("请求失败: {}", e)));
} }
} }
loading.set(false);
}); });
}); });
let is_loading = loading();
rsx! { rsx! {
div { class: "min-h-screen flex items-center justify-center bg-paper-theme", 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", 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", r#type: "text",
placeholder: "用户名或邮箱", placeholder: "用户名或邮箱",
value: username(), value: username(),
disabled: is_loading,
oninput: move |v: String| username.set(v), oninput: move |v: String| username.set(v),
onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })), 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", r#type: "password",
placeholder: "密码", placeholder: "密码",
value: password(), value: password(),
disabled: is_loading,
oninput: move |v: String| password.set(v), oninput: move |v: String| password.set(v),
onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })), onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })),
} }
} }
button { button {
class: "{BUTTON_PRIMARY_CLASS}", class: "{BUTTON_PRIMARY_CLASS}",
class: if is_loading { "opacity-60 cursor-not-allowed" },
disabled: is_loading,
onclick: move |_| on_submit(()), onclick: move |_| on_submit(()),
"登录" if is_loading { "登录中..." } else { "登录" }
} }
Link { 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", 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 confirm_password = use_signal(|| "".to_string());
let mut error = use_signal(|| None::<String>); let mut error = use_signal(|| None::<String>);
let mut success = use_signal(|| false); 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); error.set(None);
success.set(false); success.set(false);
@ -31,6 +35,8 @@ pub fn Register() -> Element {
let email_val = email(); let email_val = email();
let password_val = password(); let password_val = password();
loading.set(true);
spawn(async move { spawn(async move {
match register(username_val, email_val, password_val).await { match register(username_val, email_val, password_val).await {
Ok(AuthResponse { success: true, .. }) => { Ok(AuthResponse { success: true, .. }) => {
@ -47,8 +53,11 @@ pub fn Register() -> Element {
error.set(Some(format!("请求失败: {}", e))); error.set(Some(format!("请求失败: {}", e)));
} }
} }
loading.set(false);
}); });
}; });
let is_loading = loading();
rsx! { rsx! {
div { class: "min-h-screen flex items-center justify-center bg-paper-theme", div { class: "min-h-screen flex items-center justify-center bg-paper-theme",
@ -81,6 +90,7 @@ pub fn Register() -> Element {
r#type: "text", r#type: "text",
placeholder: "3-50 位字符", placeholder: "3-50 位字符",
value: username(), value: username(),
disabled: is_loading,
oninput: move |v: String| username.set(v), oninput: move |v: String| username.set(v),
onkeydown: None, onkeydown: None,
} }
@ -91,6 +101,7 @@ pub fn Register() -> Element {
r#type: "email", r#type: "email",
placeholder: "your@email.com", placeholder: "your@email.com",
value: email(), value: email(),
disabled: is_loading,
oninput: move |v: String| email.set(v), oninput: move |v: String| email.set(v),
onkeydown: None, onkeydown: None,
} }
@ -101,6 +112,7 @@ pub fn Register() -> Element {
r#type: "password", r#type: "password",
placeholder: "至少 8 位", placeholder: "至少 8 位",
value: password(), value: password(),
disabled: is_loading,
oninput: move |v: String| password.set(v), oninput: move |v: String| password.set(v),
onkeydown: None, onkeydown: None,
} }
@ -111,14 +123,17 @@ pub fn Register() -> Element {
r#type: "password", r#type: "password",
placeholder: "再次输入密码", placeholder: "再次输入密码",
value: confirm_password(), value: confirm_password(),
disabled: is_loading,
oninput: move |v: String| confirm_password.set(v), oninput: move |v: String| confirm_password.set(v),
onkeydown: None, onkeydown: None,
} }
} }
button { button {
class: "{BUTTON_PRIMARY_CLASS}", 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", p { class: "mt-4 text-center text-sm text-paper-secondary",