Compare commits
No commits in common. "792b06a2ebbc6bfc2bf24d7a78f9e4ce635ec06a" and "ec2f3e313e516ad875018a2816a5dd07e3f32d3d" have entirely different histories.
792b06a2eb
...
ec2f3e313e
File diff suppressed because it is too large
Load Diff
@ -1,205 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
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,9 +28,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: msg,
|
message: msg,
|
||||||
error_code: Some("rate_limited".into()),
|
error_code: Some("rate_limited".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,9 +37,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: e,
|
message: e,
|
||||||
error_code: Some("invalid_input".into()),
|
error_code: Some("invalid_input".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Err(e) = validate_comment_email(&author_email) {
|
if let Err(e) = validate_comment_email(&author_email) {
|
||||||
@ -50,9 +44,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: e,
|
message: e,
|
||||||
error_code: Some("invalid_input".into()),
|
error_code: Some("invalid_input".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(ref url) = author_url {
|
if let Some(ref url) = author_url {
|
||||||
@ -61,9 +52,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: e,
|
message: e,
|
||||||
error_code: Some("invalid_input".into()),
|
error_code: Some("invalid_input".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,9 +60,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: e,
|
message: e,
|
||||||
error_code: Some("invalid_input".into()),
|
error_code: Some("invalid_input".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,9 +79,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: "文章不存在".to_string(),
|
message: "文章不存在".to_string(),
|
||||||
error_code: Some("post_not_found".into()),
|
error_code: Some("post_not_found".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Some(row) => {
|
Some(row) => {
|
||||||
@ -107,9 +89,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: "文章不存在".to_string(),
|
message: "文章不存在".to_string(),
|
||||||
error_code: Some("post_not_found".into()),
|
error_code: Some("post_not_found".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -131,9 +110,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: "父评论不存在".to_string(),
|
message: "父评论不存在".to_string(),
|
||||||
error_code: Some("parent_not_found".into()),
|
error_code: Some("parent_not_found".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Some(row) => {
|
Some(row) => {
|
||||||
@ -146,9 +122,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: "父评论不存在".to_string(),
|
message: "父评论不存在".to_string(),
|
||||||
error_code: Some("parent_not_found".into()),
|
error_code: Some("parent_not_found".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if parent_status != "approved" {
|
if parent_status != "approved" {
|
||||||
@ -156,9 +129,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: "父评论未通过审核".to_string(),
|
message: "父评论未通过审核".to_string(),
|
||||||
error_code: Some("parent_not_approved".into()),
|
error_code: Some("parent_not_approved".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,9 +138,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: "评论嵌套层级过深".to_string(),
|
message: "评论嵌套层级过深".to_string(),
|
||||||
error_code: Some("too_deep".into()),
|
error_code: Some("too_deep".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -198,9 +165,6 @@ pub async fn create_comment(
|
|||||||
success: false,
|
success: false,
|
||||||
message: "请勿重复提交".to_string(),
|
message: "请勿重复提交".to_string(),
|
||||||
error_code: Some("duplicate".into()),
|
error_code: Some("duplicate".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,13 +187,13 @@ pub async fn create_comment(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let row = client
|
client
|
||||||
.query_one(
|
.query_one(
|
||||||
"INSERT INTO comments \
|
"INSERT INTO comments \
|
||||||
(post_id, parent_id, depth, author_name, author_email, author_url, \
|
(post_id, parent_id, depth, author_name, author_email, author_url, \
|
||||||
content_md, content_html, content_hash, status, ip_address, user_agent) \
|
content_md, content_html, content_hash, status, ip_address, user_agent) \
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11) \
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11) \
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
&[
|
&[
|
||||||
&post_id,
|
&post_id,
|
||||||
&parent_id,
|
&parent_id,
|
||||||
@ -247,10 +211,6 @@ pub async fn create_comment(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?;
|
.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_comments_by_post(post_id).await;
|
||||||
cache::invalidate_comment_count(post_id).await;
|
cache::invalidate_comment_count(post_id).await;
|
||||||
|
|
||||||
@ -258,9 +218,6 @@ pub async fn create_comment(
|
|||||||
success: true,
|
success: true,
|
||||||
message: "评论已提交,等待审核".to_string(),
|
message: "评论已提交,等待审核".to_string(),
|
||||||
error_code: None,
|
error_code: None,
|
||||||
comment_id: Some(comment_id),
|
|
||||||
avatar_url: Some(avatar_url),
|
|
||||||
depth: Some(depth),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
|
|||||||
@ -7,14 +7,12 @@ mod create;
|
|||||||
mod read;
|
mod read;
|
||||||
mod update;
|
mod update;
|
||||||
mod list;
|
mod list;
|
||||||
mod check;
|
|
||||||
|
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
pub use create::create_comment;
|
pub use create::create_comment;
|
||||||
pub use read::{get_comments, get_comment_count};
|
pub use read::{get_comments, get_comment_count};
|
||||||
pub use update::{approve_comment, spam_comment, trash_comment, batch_update_comment_status};
|
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 list::{get_pending_comments, get_pending_count, get_all_comments};
|
||||||
pub use check::check_pending_status;
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub use markdown::render_comment_markdown;
|
pub use markdown::render_comment_markdown;
|
||||||
|
|||||||
@ -17,12 +17,6 @@ pub struct CommentResponse {
|
|||||||
pub success: bool,
|
pub success: bool,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub error_code: Option<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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@ -29,9 +29,6 @@ pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError>
|
|||||||
success: false,
|
success: false,
|
||||||
message: "评论不存在".to_string(),
|
message: "评论不存在".to_string(),
|
||||||
error_code: Some("not_found".into()),
|
error_code: Some("not_found".into()),
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -66,9 +63,6 @@ pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError>
|
|||||||
success: true,
|
success: true,
|
||||||
message: "已通过".to_string(),
|
message: "已通过".to_string(),
|
||||||
error_code: None,
|
error_code: None,
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
@ -119,9 +113,6 @@ pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
|||||||
success: true,
|
success: true,
|
||||||
message: "已标记为垃圾".to_string(),
|
message: "已标记为垃圾".to_string(),
|
||||||
error_code: None,
|
error_code: None,
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
@ -169,9 +160,6 @@ pub async fn trash_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
|||||||
success: true,
|
success: true,
|
||||||
message: "已删除".to_string(),
|
message: "已删除".to_string(),
|
||||||
error_code: None,
|
error_code: None,
|
||||||
comment_id: None,
|
|
||||||
avatar_url: None,
|
|
||||||
depth: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
|
|||||||
@ -38,50 +38,53 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div { class: "space-y-3",
|
div { class: "space-y-3",
|
||||||
div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
|
if !is_reply {
|
||||||
div {
|
div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
|
||||||
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: "text",
|
||||||
|
placeholder: "你的昵称",
|
||||||
|
value: "{author_name}",
|
||||||
|
disabled: submitting(),
|
||||||
|
oninput: move |e| author_name.set(e.value()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
input {
|
div {
|
||||||
class: INPUT_CLASS,
|
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||||
r#type: "text",
|
"邮箱 *"
|
||||||
placeholder: "你的昵称",
|
}
|
||||||
value: "{author_name}",
|
input {
|
||||||
disabled: submitting(),
|
class: INPUT_CLASS,
|
||||||
oninput: move |e| author_name.set(e.value()),
|
r#type: "email",
|
||||||
|
placeholder: "your@email.com",
|
||||||
|
value: "{author_email}",
|
||||||
|
disabled: submitting(),
|
||||||
|
oninput: move |e| author_email.set(e.value()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div {
|
div {
|
||||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||||
"邮箱 *"
|
"网站"
|
||||||
}
|
}
|
||||||
input {
|
input {
|
||||||
class: INPUT_CLASS,
|
class: INPUT_CLASS,
|
||||||
r#type: "email",
|
r#type: "url",
|
||||||
placeholder: "your@email.com",
|
placeholder: "https://example.com(可选)",
|
||||||
value: "{author_email}",
|
value: "{author_url}",
|
||||||
disabled: submitting(),
|
disabled: submitting(),
|
||||||
oninput: move |e| author_email.set(e.value()),
|
oninput: move |e| author_url.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 {
|
textarea {
|
||||||
class: "{INPUT_CLASS} min-h-[100px] resize-y",
|
class: "{INPUT_CLASS} min-h-[100px] resize-y",
|
||||||
|
placeholder: "写下你的评论…",
|
||||||
value: "{content_md}",
|
value: "{content_md}",
|
||||||
disabled: submitting(),
|
disabled: submitting(),
|
||||||
oninput: move |e| content_md.set(e.value()),
|
oninput: move |e| content_md.set(e.value()),
|
||||||
|
|||||||
@ -1,63 +1,14 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::models::comment::PublicComment;
|
use crate::models::comment::PublicComment;
|
||||||
use crate::hooks::comment_storage::PendingComment;
|
|
||||||
use crate::components::comments::item::CommentItem;
|
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]
|
#[component]
|
||||||
pub fn CommentList(
|
pub fn CommentList(comments: Vec<PublicComment>, post_id: i32) -> Element {
|
||||||
comments: Vec<PublicComment>,
|
|
||||||
pending: Vec<PendingComment>,
|
|
||||||
post_id: i32,
|
|
||||||
) -> Element {
|
|
||||||
let merged = merge_comments(comments, pending);
|
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "space-y-0 divide-y divide-gray-100 dark:divide-[#2a2a2a]",
|
div { class: "space-y-0 divide-y divide-gray-100 dark:divide-[#2a2a2a]",
|
||||||
for item in merged {
|
for comment in comments {
|
||||||
let key_id = match &item {
|
CommentItem { comment, post_id }
|
||||||
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,5 +2,4 @@ pub mod section;
|
|||||||
pub mod form;
|
pub mod form;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod pending_item;
|
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
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,55 +1,21 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::api::comments::{check_pending_status, get_comments, CommentTreeResponse};
|
use crate::api::comments::{get_comments, CommentTreeResponse};
|
||||||
use crate::components::comments::form::CommentForm;
|
use crate::components::comments::form::CommentForm;
|
||||||
use crate::components::comments::list::CommentList;
|
use crate::components::comments::list::CommentList;
|
||||||
use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
|
use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
|
||||||
use crate::hooks::comment_storage::{self, PendingComment};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct CommentContext {
|
pub struct CommentContext {
|
||||||
pub active_reply: Signal<Option<i64>>,
|
pub active_reply: Signal<Option<i64>>,
|
||||||
pub refresh_trigger: Signal<bool>,
|
pub refresh_trigger: Signal<bool>,
|
||||||
pub pending_comments: Signal<Vec<PendingComment>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn CommentSection(post_id: i32) -> Element {
|
pub fn CommentSection(post_id: i32) -> Element {
|
||||||
let ctx = use_context_provider(|| {
|
let ctx = use_context_provider(|| CommentContext {
|
||||||
let pending: Vec<PendingComment> = comment_storage::load_pending_comments(post_id);
|
active_reply: Signal::new(None),
|
||||||
comment_storage::prune_all_expired();
|
refresh_trigger: Signal::new(false),
|
||||||
|
|
||||||
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 || {
|
let comments_resource = use_server_future(move || {
|
||||||
@ -61,28 +27,21 @@ pub fn CommentSection(post_id: i32) -> Element {
|
|||||||
|
|
||||||
match data.as_ref().map(|r| r.as_ref()) {
|
match data.as_ref().map(|r| r.as_ref()) {
|
||||||
Some(Ok(CommentTreeResponse { comments, count })) => {
|
Some(Ok(CommentTreeResponse { comments, count })) => {
|
||||||
let approved_count = *count;
|
let 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! {
|
rsx! {
|
||||||
div { class: "space-y-8",
|
div { class: "space-y-8",
|
||||||
h2 { class: "text-xl font-bold text-paper-primary",
|
h2 { class: "text-xl font-bold text-paper-primary",
|
||||||
"评论区 ({total_count})"
|
"评论区 ({count})"
|
||||||
}
|
}
|
||||||
|
|
||||||
CommentForm { post_id, parent_id: None }
|
CommentForm { post_id, parent_id: None }
|
||||||
|
|
||||||
if !has_any {
|
if comments.is_empty() {
|
||||||
p { class: "text-paper-tertiary text-center py-8",
|
p { class: "text-paper-tertiary text-center py-8",
|
||||||
"暂无评论,成为第一个评论的人吧!"
|
"暂无评论,成为第一个评论的人吧!"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
CommentList {
|
CommentList { comments: comments.clone(), post_id }
|
||||||
comments: comments.clone(),
|
|
||||||
pending: ctx.pending_comments.read().clone(),
|
|
||||||
post_id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,198 +0,0 @@
|
|||||||
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,2 +1 @@
|
|||||||
pub mod delayed_loading;
|
pub mod delayed_loading;
|
||||||
pub mod comment_storage;
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user