Compare commits

...

12 Commits

Author SHA1 Message Date
xfy
792b06a2eb fix(comments): address code quality issues in list and pending_item
- Add key prop to CommentList for loop for Dioxus list diffing
- Rename post_id to _post_id in PendingCommentItem signature
- Remove unnecessary let _ = post_id suppression
2026-06-11 14:42:03 +08:00
xfy
8ae3299b3e feat(comments): add PendingCommentItem component
Renders pending (unapproved) comments with:
- opacity-70 for visual distinction
- amber '审核中' badge
- Client-side content_md rendering (HTML escape + newline→br)
- No reply button (server rejects replies to pending parents)
- Same depth/indent logic as approved comments
2026-06-11 14:36:05 +08:00
xfy
12a91e3b8e feat(comments): merge approved and pending comments in CommentList
- Accept both comments and pending props
- Merge into chronologically sorted list
- Route to CommentItem or PendingCommentItem per item type
2026-06-11 14:34:36 +08:00
xfy
9e658662cb fix(comments): add error logging for check_pending_status failure
Address code quality review: log warnings when the server call fails
instead of silently swallowing the error.
2026-06-11 14:33:13 +08:00
xfy
40cfd44d3a feat(comments): add pending_comments to CommentContext and sync on mount
- Extend CommentContext with pending_comments Signal
- Load pending comments from localStorage on provider init
- Run check_pending_status on mount to prune non-pending entries
- Pass both approved and pending comments to CommentList
- Include pending count in section heading
2026-06-11 14:30:49 +08:00
xfy
0f3d9fc25c fix(hooks): address code quality issues in comment_storage
- Fix is_expired: use js_sys::Date::now() on WASM, avoid format-parse roundtrip
- Make error fallback safe (expire on error)
- Restrict escape_html visibility to pub(crate)
- Only write storage in load_pending_comments when pruning actually occurs
2026-06-11 14:28:27 +08:00
xfy
fccd4c05ff feat(hooks): add comment_storage module for localStorage persistence
Provides save/load for author info (yggdrasil-comment-author) and
pending comments (yggdrasil-pending-comments) in localStorage.
- 7-day TTL with auto-pruning
- Per-post-id storage keyed by post_id string
- HTML escaping for pending content_md rendering
- All web_sys calls behind #[cfg(target_arch = "wasm32")]
2026-06-11 14:23:27 +08:00
xfy
7b3d894e96 feat(api): add CheckPendingStatus server function
Accepts Vec<i64> of comment IDs, returns their current status.
IDs not found in DB return status 'gone'. Empty vec returns early.
Used by client to prune localStorage pending comments that are
no longer pending (approved/spam/trash/deleted).
2026-06-11 14:18:49 +08:00
xfy
d63cee58c2 feat(api): return comment_id, avatar_url, depth from create_comment
- Add comment_id/avatar_url/depth Option fields to CommentResponse with serde default
- Extract RETURNING id from INSERT in create.rs
- Compute gravatar_url server-side (md5 not available in WASM)
- Return computed depth for correct client-side pending comment indentation
- All error paths return None for all new fields
2026-06-11 14:13:14 +08:00
xfy
03054d83e8 docs: add implementation plan for comment localStorage feature
8 tasks covering:
1. CommentResponse: add comment_id/avatar_url/depth fields
2. CheckPendingStatus server function
3. comment_storage hook (localStorage CRUD + TTL)
4. CommentSection: pending state in CommentContext
5. CommentList: merge approved + pending
6. PendingCommentItem component
7. CommentForm: auto-fill + save pending
8. Build + test verification

Fixes from subagent review:
- avatar_url/depth computed server-side (md5 not in WASM)
- Fix double mutable borrow in prune_all_expired
- Remove unused wasm_bindgen::JsCast imports
- Remove redundant is_expired filter in section.rs
- Fix double localStorage read in load_pending_comments
- Remove unused private helpers import in form.rs
2026-06-11 14:07:58 +08:00
xfy
fbda6373cc docs: add comment localStorage persistence + pending visibility design spec
Two features:
- Auto-fill comment form (name/email/url) from localStorage
- Show pending comments with '审核中' badge to the submitting user

Key decisions:
- Two separate localStorage keys (author info + pending comments)
- Pending comments stored by server-returned ID, not user identity
- 7-day TTL with cleanup on page load via check_pending_status API
- Separate PendingCommentItem component (no PublicComment pollution)
- content_md only (no HTML in localStorage for XSS safety)
2026-06-11 13:52:28 +08:00
xfy
880c53d2a4 fix(comments): show name/email/url fields in reply form 2026-06-11 13:30:54 +08:00
14 changed files with 1745 additions and 46 deletions

File diff suppressed because it is too large Load Diff

View 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
View 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!()
}

View File

@ -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"))]

View File

@ -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;

View File

@ -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)]

View File

@ -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"))]

View File

@ -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()),

View File

@ -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 }
},
}
}
}
}

View File

@ -2,4 +2,5 @@ pub mod section;
pub mod form;
pub mod list;
pub mod item;
pub mod pending_item;
pub mod actions;

View 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}",
}
}
}
}
}
}

View File

@ -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,
}
}
}
}

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
pub fn render_pending_content(md: &str) -> String {
let escaped = escape_html(md);
escaped.replace('\n', "<br>")
}

View File

@ -1 +1,2 @@
pub mod delayed_loading;
pub mod comment_storage;