feat(auth): add loading state to login/register forms and update AGENTS.md
This commit is contained in:
parent
f9d23d1eed
commit
efa41b42c2
168
AGENTS.md
168
AGENTS.md
@ -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.0–100.0, clamped
|
||||
WEBP_METHOD=2 # 0–6, 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 (60s–600s). 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`).
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user