Compare commits
12 Commits
ec2f3e313e
...
792b06a2eb
| Author | SHA1 | Date | |
|---|---|---|---|
| 792b06a2eb | |||
| 8ae3299b3e | |||
| 12a91e3b8e | |||
| 9e658662cb | |||
| 40cfd44d3a | |||
| 0f3d9fc25c | |||
| fccd4c05ff | |||
| 7b3d894e96 | |||
| d63cee58c2 | |||
| 03054d83e8 | |||
| fbda6373cc | |||
| 880c53d2a4 |
1027
docs/superpowers/plans/2026-06-11-comment-localstorage.md
Normal file
1027
docs/superpowers/plans/2026-06-11-comment-localstorage.md
Normal file
File diff suppressed because it is too large
Load Diff
205
docs/superpowers/specs/2026-06-11-comment-localstorage-design.md
Normal file
205
docs/superpowers/specs/2026-06-11-comment-localstorage-design.md
Normal file
@ -0,0 +1,205 @@
|
||||
# Comment localStorage Persistence + Pending Visibility
|
||||
|
||||
## Problem
|
||||
|
||||
After submitting a comment, users see only a green "评论已提交,等待审核" alert. The comment is invisible to everyone (including the author) until admin approval. Additionally, returning users must re-enter their name/email/website every time.
|
||||
|
||||
## Solution
|
||||
|
||||
Two localStorage-backed features:
|
||||
|
||||
1. **Form auto-fill**: Save author info (name/email/url) to localStorage, pre-fill on subsequent visits.
|
||||
2. **Pending comment visibility**: Store pending comments locally by server-returned ID. Display them with a "审核中" badge, visible only to the submitting browser.
|
||||
|
||||
## localStorage Keys
|
||||
|
||||
### `yggdrasil-comment-author`
|
||||
|
||||
Written on every successful comment submission. Read on `CommentForm` mount.
|
||||
|
||||
```json
|
||||
{ "name": "张三", "email": "zhang@example.com", "url": "https://example.com" }
|
||||
```
|
||||
|
||||
### `yggdrasil-pending-comments`
|
||||
|
||||
Written on successful submission with server-returned ID. Read on `CommentSection` mount. Keyed by `post_id` (string).
|
||||
|
||||
```json
|
||||
{
|
||||
"42": [
|
||||
{
|
||||
"id": 123,
|
||||
"parent_id": null,
|
||||
"depth": 0,
|
||||
"author_name": "张三",
|
||||
"author_url": null,
|
||||
"avatar_url": "https://cravatar.cn/avatar/xxx?d=mp&s=80",
|
||||
"content_md": "评论内容",
|
||||
"created_at": "2026-06-11T10:00:00Z",
|
||||
"stored_at": "2026-06-11T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Design decisions:
|
||||
- **No `content_html`** — XSS safety. Render `content_md` client-side with HTML escaping + newline→`<br>`.
|
||||
- **No `author_email`** — Already in `yggdrasil-comment-author`. Avoid PII duplication.
|
||||
- **`avatar_url`** computed at save time from the stored author email.
|
||||
- **`stored_at`** for 7-day TTL. Pruned on every read.
|
||||
- Empty post_id arrays removed on cleanup.
|
||||
|
||||
## Server-Side Changes
|
||||
|
||||
### 1. `CommentResponse` — add `comment_id`
|
||||
|
||||
```rust
|
||||
pub struct CommentResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub error_code: Option<String>,
|
||||
pub comment_id: Option<i64>, // NEW: set only on success
|
||||
}
|
||||
```
|
||||
|
||||
Backward-compatible: `Option<i64>` serde defaults to `None` for old callers.
|
||||
|
||||
### 2. `create_comment` — extract and return ID
|
||||
|
||||
The existing INSERT already uses `RETURNING id` but discards the row. Change to:
|
||||
|
||||
```rust
|
||||
let row = client
|
||||
.query_one("INSERT INTO comments ... RETURNING id", &[...])
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let comment_id: i64 = row.get(0);
|
||||
```
|
||||
|
||||
All existing error paths add `comment_id: None`. Only the final success path sets `comment_id: Some(comment_id)`.
|
||||
|
||||
### 3. New server function: `CheckPendingStatus`
|
||||
|
||||
```rust
|
||||
#[server(CheckPendingStatus, "/api")]
|
||||
pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<(i64, String)>, ServerFnError>
|
||||
```
|
||||
|
||||
- Early return empty vec if `ids.is_empty()`.
|
||||
- Query: `SELECT id, status FROM comments WHERE id = ANY($1)`
|
||||
- IDs not found in result → status `"gone"` (soft-deleted or hard-deleted).
|
||||
- Client uses this to prune localStorage entries that are no longer pending.
|
||||
|
||||
## Client-Side Changes
|
||||
|
||||
### New module: `src/hooks/comment_storage.rs`
|
||||
|
||||
Provides `use_comment_storage()` hook with:
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `save_author(name, email, url)` | Write `yggdrasil-comment-author` |
|
||||
| `load_author() -> Option<AuthorInfo>` | Read author info |
|
||||
| `save_pending_comment(post_id, comment)` | Append to `yggdrasil-pending-comments` |
|
||||
| `load_pending_comments(post_id) -> Vec<PendingComment>` | Read + prune expired (7-day TTL) |
|
||||
| `remove_pending_ids(post_id, ids)` | Remove specific IDs, clean empty post entries |
|
||||
| `prune_expired()` | Remove all entries across all posts older than 7 days |
|
||||
|
||||
All `web_sys` calls behind `#[cfg(target_arch = "wasm32")]`. Non-WASM returns defaults (empty/None).
|
||||
|
||||
Serialization via `serde_json` (already a project dependency).
|
||||
|
||||
### `CommentContext` — extend with pending state
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CommentContext {
|
||||
pub active_reply: Signal<Option<i64>>,
|
||||
pub refresh_trigger: Signal<bool>>,
|
||||
pub pending_comments: Signal<Vec<PendingComment>>, // NEW
|
||||
}
|
||||
```
|
||||
|
||||
Placing `pending_comments` in `CommentContext` ensures it survives `refresh_trigger` re-renders (blocker B2 from review).
|
||||
|
||||
### `CommentForm` changes
|
||||
|
||||
- **On mount**: call `load_author()` → set `author_name`, `author_email`, `author_url` signals if localStorage has saved values.
|
||||
- **On successful submit**:
|
||||
1. `save_author(name, email, url)` — persist form fields
|
||||
2. Construct `PendingComment` from form data + returned `comment_id`
|
||||
3. Compute `avatar_url` from stored email (same gravatar formula as server)
|
||||
4. `save_pending_comment(post_id, pending)` — persist pending comment
|
||||
5. Push to `ctx.pending_comments` signal — immediate UI update
|
||||
|
||||
### `CommentSection` changes
|
||||
|
||||
- **On mount**:
|
||||
1. `load_pending_comments(post_id)` → populate `ctx.pending_comments`
|
||||
2. `prune_expired()` — clean 7-day old entries
|
||||
3. If pending IDs exist, call `check_pending_status(ids)` → `remove_pending_ids()` for non-pending
|
||||
- **Rendering**: merge approved + pending comments sorted by `created_at`, interleaving them chronologically.
|
||||
|
||||
### New component: `PendingCommentItem`
|
||||
|
||||
Renders pending comments distinctly from approved ones:
|
||||
- Semi-transparent card style (e.g., `opacity-70`)
|
||||
- Amber badge: "审核中"
|
||||
- `content_md` rendered with HTML escaping + `\n` → `<br>`
|
||||
- **No reply button** — server rejects replies to non-approved parents
|
||||
- **No admin actions** — pending comments from localStorage are visitor-submitted
|
||||
|
||||
### `CommentList` changes
|
||||
|
||||
Accept both `comments: Vec<PublicComment>` and `pending: Vec<PendingComment>`. Merge into a sorted iterator by `created_at` and render either `CommentItem` or `PendingCommentItem` per item.
|
||||
|
||||
For threaded display: pending replies appear under their `parent_id` parent, using the same `depth` indentation logic.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Submit comment
|
||||
→ server returns { success: true, comment_id: 123 }
|
||||
→ save_author() to localStorage
|
||||
→ save_pending_comment() to localStorage
|
||||
→ push to pending_comments Signal
|
||||
→ UI shows comment with "审核中" badge
|
||||
|
||||
Page load / CommentSection mount
|
||||
→ load_pending_comments(post_id) from localStorage
|
||||
→ prune_expired() (7-day TTL)
|
||||
→ check_pending_status(pending_ids) via server
|
||||
→ remove_pending_ids() for non-pending (approved/spam/trash/gone)
|
||||
→ merge approved + pending → render chronologically
|
||||
|
||||
Admin approves comment
|
||||
→ next page load: check_pending_status() returns "approved"
|
||||
→ remove_pending_ids() removes it from localStorage
|
||||
→ comment appears normally via get_comments() API
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/api/comments/types.rs` | Add `comment_id: Option<i64>` to `CommentResponse` |
|
||||
| `src/api/comments/create.rs` | Extract returned ID, populate `comment_id` in response |
|
||||
| `src/api/comments/mod.rs` | Export new `check_pending_status` |
|
||||
| `src/api/comments/check.rs` | **NEW**: `CheckPendingStatus` server function |
|
||||
| `src/hooks/comment_storage.rs` | **NEW**: localStorage hook + `AuthorInfo`/`PendingComment` structs |
|
||||
| `src/hooks/mod.rs` | Export `comment_storage` module |
|
||||
| `src/components/comments/section.rs` | Load pending, call check, merge for rendering |
|
||||
| `src/components/comments/form.rs` | Pre-fill from localStorage, save on submit |
|
||||
| `src/components/comments/list.rs` | Accept both comment types, merge sorted |
|
||||
| `src/components/comments/item.rs` | No change (approved comments unchanged) |
|
||||
| `src/components/comments/pending_item.rs` | **NEW**: `PendingCommentItem` component |
|
||||
| `src/components/comments/mod.rs` | Export `pending_item` |
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit test: `CommentResponse` deserialization without `comment_id` → `None`
|
||||
- Unit test: `PendingComment` serde roundtrip
|
||||
- Unit test: `check_pending_status` with empty vec → empty result
|
||||
- Unit test: `check_pending_status` with mix of pending/approved/gone IDs
|
||||
- Integration: submit comment → verify localStorage written → verify pending visible → verify cleanup after admin approval
|
||||
47
src/api/comments/check.rs
Normal file
47
src/api/comments/check.rs
Normal file
@ -0,0 +1,47 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PendingStatusItem {
|
||||
pub id: i64,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[server(CheckPendingStatus, "/api")]
|
||||
pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<PendingStatusItem>, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::db::pool::get_conn;
|
||||
use crate::api::error::AppError;
|
||||
|
||||
if ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT id, status FROM comments WHERE id = ANY($1)",
|
||||
&[&ids],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
let found: std::collections::HashMap<i64, String> = rows
|
||||
.iter()
|
||||
.map(|r| (r.get::<_, i64>(0), r.get::<_, String>(1)))
|
||||
.collect();
|
||||
|
||||
let result: Vec<PendingStatusItem> = ids
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let status = found.get(&id).cloned().unwrap_or_else(|| "gone".to_string());
|
||||
PendingStatusItem { id, status }
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
}
|
||||
@ -28,6 +28,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: msg,
|
||||
error_code: Some("rate_limited".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -37,6 +40,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: e,
|
||||
error_code: Some("invalid_input".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
if let Err(e) = validate_comment_email(&author_email) {
|
||||
@ -44,6 +50,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: e,
|
||||
error_code: Some("invalid_input".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
if let Some(ref url) = author_url {
|
||||
@ -52,6 +61,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: e,
|
||||
error_code: Some("invalid_input".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -60,6 +72,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: e,
|
||||
error_code: Some("invalid_input".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
|
||||
@ -79,6 +94,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: "文章不存在".to_string(),
|
||||
error_code: Some("post_not_found".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
Some(row) => {
|
||||
@ -89,6 +107,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: "文章不存在".to_string(),
|
||||
error_code: Some("post_not_found".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -110,6 +131,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: "父评论不存在".to_string(),
|
||||
error_code: Some("parent_not_found".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
Some(row) => {
|
||||
@ -122,6 +146,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: "父评论不存在".to_string(),
|
||||
error_code: Some("parent_not_found".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
if parent_status != "approved" {
|
||||
@ -129,6 +156,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: "父评论未通过审核".to_string(),
|
||||
error_code: Some("parent_not_approved".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
|
||||
@ -138,6 +168,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: "评论嵌套层级过深".to_string(),
|
||||
error_code: Some("too_deep".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -165,6 +198,9 @@ pub async fn create_comment(
|
||||
success: false,
|
||||
message: "请勿重复提交".to_string(),
|
||||
error_code: Some("duplicate".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
|
||||
@ -187,13 +223,13 @@ pub async fn create_comment(
|
||||
None
|
||||
};
|
||||
|
||||
client
|
||||
let row = client
|
||||
.query_one(
|
||||
"INSERT INTO comments \
|
||||
(post_id, parent_id, depth, author_name, author_email, author_url, \
|
||||
content_md, content_html, content_hash, status, ip_address, user_agent) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11) \
|
||||
RETURNING id",
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11) \
|
||||
RETURNING id",
|
||||
&[
|
||||
&post_id,
|
||||
&parent_id,
|
||||
@ -211,6 +247,10 @@ pub async fn create_comment(
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
|
||||
let comment_id: i64 = row.get(0);
|
||||
|
||||
let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email);
|
||||
|
||||
cache::invalidate_comments_by_post(post_id).await;
|
||||
cache::invalidate_comment_count(post_id).await;
|
||||
|
||||
@ -218,6 +258,9 @@ pub async fn create_comment(
|
||||
success: true,
|
||||
message: "评论已提交,等待审核".to_string(),
|
||||
error_code: None,
|
||||
comment_id: Some(comment_id),
|
||||
avatar_url: Some(avatar_url),
|
||||
depth: Some(depth),
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
|
||||
@ -7,12 +7,14 @@ mod create;
|
||||
mod read;
|
||||
mod update;
|
||||
mod list;
|
||||
mod check;
|
||||
|
||||
pub use types::*;
|
||||
pub use create::create_comment;
|
||||
pub use read::{get_comments, get_comment_count};
|
||||
pub use update::{approve_comment, spam_comment, trash_comment, batch_update_comment_status};
|
||||
pub use list::{get_pending_comments, get_pending_count, get_all_comments};
|
||||
pub use check::check_pending_status;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub use markdown::render_comment_markdown;
|
||||
|
||||
@ -17,6 +17,12 @@ pub struct CommentResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub error_code: Option<String>,
|
||||
#[serde(default)]
|
||||
pub comment_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub depth: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@ -29,6 +29,9 @@ pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError>
|
||||
success: false,
|
||||
message: "评论不存在".to_string(),
|
||||
error_code: Some("not_found".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -63,6 +66,9 @@ pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError>
|
||||
success: true,
|
||||
message: "已通过".to_string(),
|
||||
error_code: None,
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
@ -113,6 +119,9 @@ pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
||||
success: true,
|
||||
message: "已标记为垃圾".to_string(),
|
||||
error_code: None,
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
@ -160,6 +169,9 @@ pub async fn trash_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
||||
success: true,
|
||||
message: "已删除".to_string(),
|
||||
error_code: None,
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
|
||||
@ -38,53 +38,50 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
||||
}
|
||||
|
||||
div { class: "space-y-3",
|
||||
if !is_reply {
|
||||
div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"昵称 *"
|
||||
}
|
||||
input {
|
||||
class: INPUT_CLASS,
|
||||
r#type: "text",
|
||||
placeholder: "你的昵称",
|
||||
value: "{author_name}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| author_name.set(e.value()),
|
||||
}
|
||||
div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"昵称 *"
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"邮箱 *"
|
||||
}
|
||||
input {
|
||||
class: INPUT_CLASS,
|
||||
r#type: "email",
|
||||
placeholder: "your@email.com",
|
||||
value: "{author_email}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| author_email.set(e.value()),
|
||||
}
|
||||
input {
|
||||
class: INPUT_CLASS,
|
||||
r#type: "text",
|
||||
placeholder: "你的昵称",
|
||||
value: "{author_name}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| author_name.set(e.value()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"网站"
|
||||
"邮箱 *"
|
||||
}
|
||||
input {
|
||||
class: INPUT_CLASS,
|
||||
r#type: "url",
|
||||
placeholder: "https://example.com(可选)",
|
||||
value: "{author_url}",
|
||||
r#type: "email",
|
||||
placeholder: "your@email.com",
|
||||
value: "{author_email}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| author_url.set(e.value()),
|
||||
oninput: move |e| author_email.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"网站"
|
||||
}
|
||||
input {
|
||||
class: INPUT_CLASS,
|
||||
r#type: "url",
|
||||
placeholder: "https://example.com(可选)",
|
||||
value: "{author_url}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| author_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
class: "{INPUT_CLASS} min-h-[100px] resize-y",
|
||||
placeholder: "写下你的评论…",
|
||||
value: "{content_md}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| content_md.set(e.value()),
|
||||
|
||||
@ -1,14 +1,63 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::models::comment::PublicComment;
|
||||
use crate::hooks::comment_storage::PendingComment;
|
||||
use crate::components::comments::item::CommentItem;
|
||||
use crate::components::comments::pending_item::PendingCommentItem;
|
||||
|
||||
enum MergedComment {
|
||||
Approved(PublicComment),
|
||||
Pending(PendingComment),
|
||||
}
|
||||
|
||||
fn merge_comments(
|
||||
approved: Vec<PublicComment>,
|
||||
pending: Vec<PendingComment>,
|
||||
) -> Vec<MergedComment> {
|
||||
let mut merged: Vec<MergedComment> = approved
|
||||
.into_iter()
|
||||
.map(MergedComment::Approved)
|
||||
.chain(pending.into_iter().map(MergedComment::Pending))
|
||||
.collect();
|
||||
|
||||
merged.sort_by(|a, b| {
|
||||
let time_a = match a {
|
||||
MergedComment::Approved(c) => c.created_at_iso.as_str(),
|
||||
MergedComment::Pending(c) => c.created_at.as_str(),
|
||||
};
|
||||
let time_b = match b {
|
||||
MergedComment::Approved(c) => c.created_at_iso.as_str(),
|
||||
MergedComment::Pending(c) => c.created_at.as_str(),
|
||||
};
|
||||
time_a.cmp(time_b)
|
||||
});
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CommentList(comments: Vec<PublicComment>, post_id: i32) -> Element {
|
||||
pub fn CommentList(
|
||||
comments: Vec<PublicComment>,
|
||||
pending: Vec<PendingComment>,
|
||||
post_id: i32,
|
||||
) -> Element {
|
||||
let merged = merge_comments(comments, pending);
|
||||
|
||||
rsx! {
|
||||
div { class: "space-y-0 divide-y divide-gray-100 dark:divide-[#2a2a2a]",
|
||||
for comment in comments {
|
||||
CommentItem { comment, post_id }
|
||||
for item in merged {
|
||||
let key_id = match &item {
|
||||
MergedComment::Approved(c) => c.id,
|
||||
MergedComment::Pending(c) => c.id,
|
||||
};
|
||||
match item {
|
||||
MergedComment::Approved(comment) => rsx! {
|
||||
CommentItem { key: "{key_id}", comment, post_id }
|
||||
},
|
||||
MergedComment::Pending(comment) => rsx! {
|
||||
PendingCommentItem { key: "{key_id}", comment, post_id }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,4 +2,5 @@ pub mod section;
|
||||
pub mod form;
|
||||
pub mod list;
|
||||
pub mod item;
|
||||
pub mod pending_item;
|
||||
pub mod actions;
|
||||
|
||||
70
src/components/comments/pending_item.rs
Normal file
70
src/components/comments/pending_item.rs
Normal file
@ -0,0 +1,70 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::hooks::comment_storage::{PendingComment, render_pending_content};
|
||||
|
||||
#[component]
|
||||
pub fn PendingCommentItem(comment: PendingComment, _post_id: i32) -> Element {
|
||||
|
||||
let depth = if comment.parent_id.is_none() && comment.depth > 0 {
|
||||
0
|
||||
} else {
|
||||
comment.depth
|
||||
};
|
||||
|
||||
let indent = depth.min(6) * 24;
|
||||
let content_html = render_pending_content(&comment.content_md);
|
||||
|
||||
let author_element = match &comment.author_url {
|
||||
Some(url) if !url.is_empty() => rsx! {
|
||||
a {
|
||||
href: "{url}",
|
||||
rel: "nofollow noopener",
|
||||
target: "_blank",
|
||||
class: "font-medium text-paper-primary hover:text-paper-accent transition-colors",
|
||||
"{comment.author_name}"
|
||||
}
|
||||
},
|
||||
_ => rsx! {
|
||||
span { class: "font-medium text-paper-primary",
|
||||
"{comment.author_name}"
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "py-4 opacity-70",
|
||||
style: "margin-left: {indent}px",
|
||||
|
||||
div { class: "flex gap-3",
|
||||
img {
|
||||
src: "{comment.avatar_url}",
|
||||
alt: "{comment.author_name} 的头像",
|
||||
loading: "lazy",
|
||||
decoding: "async",
|
||||
class: "w-8 h-8 rounded-full shrink-0 mt-0.5 bg-gray-200 dark:bg-[#2a2a2a]",
|
||||
}
|
||||
|
||||
div { class: "flex-1 min-w-0",
|
||||
div { class: "flex items-center gap-1.5 text-sm mb-1.5 flex-wrap",
|
||||
{author_element}
|
||||
span { class: "text-paper-tertiary", "·" }
|
||||
span {
|
||||
class: "text-paper-tertiary",
|
||||
"刚刚"
|
||||
}
|
||||
span {
|
||||
class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||
"审核中"
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
class: "prose prose-sm dark:prose-invert max-w-none text-paper-secondary",
|
||||
dangerous_inner_html: "{content_html}",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,55 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::comments::{get_comments, CommentTreeResponse};
|
||||
use crate::api::comments::{check_pending_status, get_comments, CommentTreeResponse};
|
||||
use crate::components::comments::form::CommentForm;
|
||||
use crate::components::comments::list::CommentList;
|
||||
use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
|
||||
use crate::hooks::comment_storage::{self, PendingComment};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CommentContext {
|
||||
pub active_reply: Signal<Option<i64>>,
|
||||
pub refresh_trigger: Signal<bool>,
|
||||
pub pending_comments: Signal<Vec<PendingComment>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CommentSection(post_id: i32) -> Element {
|
||||
let ctx = use_context_provider(|| CommentContext {
|
||||
active_reply: Signal::new(None),
|
||||
refresh_trigger: Signal::new(false),
|
||||
let ctx = use_context_provider(|| {
|
||||
let pending: Vec<PendingComment> = comment_storage::load_pending_comments(post_id);
|
||||
comment_storage::prune_all_expired();
|
||||
|
||||
CommentContext {
|
||||
active_reply: Signal::new(None),
|
||||
refresh_trigger: Signal::new(false),
|
||||
pending_comments: Signal::new(pending),
|
||||
}
|
||||
});
|
||||
|
||||
use_future(move || {
|
||||
let mut pending = ctx.pending_comments;
|
||||
async move {
|
||||
let ids: Vec<i64> = pending().iter().map(|c| c.id).collect();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
match check_pending_status(ids).await {
|
||||
Ok(statuses) => {
|
||||
let to_remove: Vec<i64> = statuses
|
||||
.into_iter()
|
||||
.filter(|s| s.status != "pending")
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
if !to_remove.is_empty() {
|
||||
comment_storage::remove_pending_ids(post_id, &to_remove);
|
||||
pending.write().retain(|c| !to_remove.contains(&c.id));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("check_pending_status failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let comments_resource = use_server_future(move || {
|
||||
@ -27,21 +61,28 @@ pub fn CommentSection(post_id: i32) -> Element {
|
||||
|
||||
match data.as_ref().map(|r| r.as_ref()) {
|
||||
Some(Ok(CommentTreeResponse { comments, count })) => {
|
||||
let count = *count;
|
||||
let approved_count = *count;
|
||||
let pending_count = ctx.pending_comments.read().len() as i64;
|
||||
let total_count = approved_count + pending_count;
|
||||
let has_any = approved_count > 0 || pending_count > 0;
|
||||
rsx! {
|
||||
div { class: "space-y-8",
|
||||
h2 { class: "text-xl font-bold text-paper-primary",
|
||||
"评论区 ({count})"
|
||||
"评论区 ({total_count})"
|
||||
}
|
||||
|
||||
CommentForm { post_id, parent_id: None }
|
||||
|
||||
if comments.is_empty() {
|
||||
if !has_any {
|
||||
p { class: "text-paper-tertiary text-center py-8",
|
||||
"暂无评论,成为第一个评论的人吧!"
|
||||
}
|
||||
} else {
|
||||
CommentList { comments: comments.clone(), post_id }
|
||||
CommentList {
|
||||
comments: comments.clone(),
|
||||
pending: ctx.pending_comments.read().clone(),
|
||||
post_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
198
src/hooks/comment_storage.rs
Normal file
198
src/hooks/comment_storage.rs
Normal file
@ -0,0 +1,198 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const AUTHOR_KEY: &str = "yggdrasil-comment-author";
|
||||
const PENDING_KEY: &str = "yggdrasil-pending-comments";
|
||||
const TTL_DAYS: i64 = 7;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthorInfo {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PendingComment {
|
||||
pub id: i64,
|
||||
pub parent_id: Option<i64>,
|
||||
pub depth: i32,
|
||||
pub author_name: String,
|
||||
pub author_url: Option<String>,
|
||||
pub avatar_url: String,
|
||||
pub content_md: String,
|
||||
pub created_at: String,
|
||||
pub stored_at: String,
|
||||
}
|
||||
|
||||
type PendingMap = std::collections::HashMap<String, Vec<PendingComment>>;
|
||||
|
||||
fn read_storage(key: &str) -> Option<String> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let window = web_sys::window()?;
|
||||
let storage = window.local_storage().ok()??;
|
||||
storage.get_item(key).ok()?
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn write_storage(key: &str, value: &str) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Ok(Some(storage)) = window.local_storage() {
|
||||
let _ = storage.set_item(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
js_sys::Date::now() as i64
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(stored_at: &str) -> bool {
|
||||
let Ok(dt) = DateTime::parse_from_rfc3339(stored_at) else {
|
||||
return true;
|
||||
};
|
||||
let now_ms = now_millis();
|
||||
let stored_ms = dt.timestamp_millis();
|
||||
(now_ms - stored_ms) > (TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
pub fn save_author(name: &str, email: &str, url: &str) {
|
||||
let info = AuthorInfo {
|
||||
name: name.to_string(),
|
||||
email: email.to_string(),
|
||||
url: url.to_string(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&info) {
|
||||
write_storage(AUTHOR_KEY, &json);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_author() -> Option<AuthorInfo> {
|
||||
let json = read_storage(AUTHOR_KEY)?;
|
||||
serde_json::from_str(&json).ok()
|
||||
}
|
||||
|
||||
pub fn save_pending_comment(post_id: i32, comment: PendingComment) {
|
||||
let mut map: PendingMap = load_all_pending();
|
||||
let key = post_id.to_string();
|
||||
let list = map.entry(key).or_default();
|
||||
|
||||
if list.iter().any(|c| c.id == comment.id) {
|
||||
return;
|
||||
}
|
||||
list.push(comment);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&map) {
|
||||
write_storage(PENDING_KEY, &json);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_pending_comments(post_id: i32) -> Vec<PendingComment> {
|
||||
let mut map = load_all_pending();
|
||||
let key = post_id.to_string();
|
||||
|
||||
let comments = map.remove(&key).unwrap_or_default();
|
||||
let original_len = comments.len();
|
||||
let non_expired: Vec<PendingComment> = comments
|
||||
.into_iter()
|
||||
.filter(|c| !is_expired(&c.stored_at))
|
||||
.collect();
|
||||
|
||||
let pruned = non_expired.len() != original_len;
|
||||
if !non_expired.is_empty() {
|
||||
map.insert(key, non_expired.clone());
|
||||
}
|
||||
if pruned || non_expired.is_empty() {
|
||||
if let Ok(json) = serde_json::to_string(&map) {
|
||||
write_storage(PENDING_KEY, &json);
|
||||
}
|
||||
}
|
||||
|
||||
non_expired
|
||||
}
|
||||
|
||||
pub fn remove_pending_ids(post_id: i32, ids: &[i64]) {
|
||||
let mut map = load_all_pending();
|
||||
let key = post_id.to_string();
|
||||
|
||||
let should_remove = if let Some(comments) = map.get_mut(&key) {
|
||||
comments.retain(|c| !ids.contains(&c.id));
|
||||
comments.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if should_remove {
|
||||
map.remove(&key);
|
||||
}
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&map) {
|
||||
write_storage(PENDING_KEY, &json);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prune_all_expired() {
|
||||
let mut map = load_all_pending();
|
||||
let mut changed = false;
|
||||
|
||||
let keys: Vec<String> = map.keys().cloned().collect();
|
||||
for key in keys {
|
||||
let should_remove = if let Some(comments) = map.get_mut(&key) {
|
||||
let before = comments.len();
|
||||
comments.retain(|c| !is_expired(&c.stored_at));
|
||||
if comments.len() != before {
|
||||
changed = true;
|
||||
}
|
||||
comments.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if should_remove {
|
||||
map.remove(&key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if let Ok(json) = serde_json::to_string(&map) {
|
||||
write_storage(PENDING_KEY, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_all_pending() -> PendingMap {
|
||||
let json = match read_storage(PENDING_KEY) {
|
||||
Some(j) => j,
|
||||
None => return PendingMap::new(),
|
||||
};
|
||||
serde_json::from_str(&json).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
pub fn render_pending_content(md: &str) -> String {
|
||||
let escaped = escape_html(md);
|
||||
escaped.replace('\n', "<br>")
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
pub mod delayed_loading;
|
||||
pub mod comment_storage;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user