Compare commits

..

No commits in common. "792b06a2ebbc6bfc2bf24d7a78f9e4ce635ec06a" and "ec2f3e313e516ad875018a2816a5dd07e3f32d3d" have entirely different histories.

14 changed files with 46 additions and 1745 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -28,9 +28,6 @@ pub async fn create_comment(
success: false,
message: msg,
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,
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) {
@ -50,9 +44,6 @@ 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 {
@ -61,9 +52,6 @@ pub async fn create_comment(
success: false,
message: e,
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,
message: e,
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,
message: "文章不存在".to_string(),
error_code: Some("post_not_found".into()),
comment_id: None,
avatar_url: None,
depth: None,
});
}
Some(row) => {
@ -107,9 +89,6 @@ 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,
});
}
}
@ -131,9 +110,6 @@ 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) => {
@ -146,9 +122,6 @@ 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" {
@ -156,9 +129,6 @@ 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,
});
}
@ -168,9 +138,6 @@ pub async fn create_comment(
success: false,
message: "评论嵌套层级过深".to_string(),
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,
message: "请勿重复提交".to_string(),
error_code: Some("duplicate".into()),
comment_id: None,
avatar_url: None,
depth: None,
});
}
@ -223,13 +187,13 @@ pub async fn create_comment(
None
};
let row = client
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,
@ -247,10 +211,6 @@ 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;
@ -258,9 +218,6 @@ 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,14 +7,12 @@ 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,12 +17,6 @@ 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,9 +29,6 @@ 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,
});
}
};
@ -66,9 +63,6 @@ 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"))]
@ -119,9 +113,6 @@ 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"))]
@ -169,9 +160,6 @@ 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,50 +38,53 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
}
div { class: "space-y-3",
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",
"昵称 *"
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()),
}
}
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: "email",
placeholder: "your@email.com",
value: "{author_email}",
disabled: submitting(),
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: "email",
placeholder: "your@email.com",
value: "{author_email}",
r#type: "url",
placeholder: "https://example.com可选",
value: "{author_url}",
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 {
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,63 +1,14 @@
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>,
pending: Vec<PendingComment>,
post_id: i32,
) -> Element {
let merged = merge_comments(comments, pending);
pub fn CommentList(comments: Vec<PublicComment>, post_id: i32) -> Element {
rsx! {
div { class: "space-y-0 divide-y divide-gray-100 dark:divide-[#2a2a2a]",
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 }
},
}
for comment in comments {
CommentItem { comment, post_id }
}
}
}

View File

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

View File

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

View File

@ -1,55 +1,21 @@
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::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(|| {
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 ctx = use_context_provider(|| CommentContext {
active_reply: Signal::new(None),
refresh_trigger: Signal::new(false),
});
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()) {
Some(Ok(CommentTreeResponse { comments, 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;
let count = *count;
rsx! {
div { class: "space-y-8",
h2 { class: "text-xl font-bold text-paper-primary",
"评论区 ({total_count})"
"评论区 ({count})"
}
CommentForm { post_id, parent_id: None }
if !has_any {
if comments.is_empty() {
p { class: "text-paper-tertiary text-center py-8",
"暂无评论,成为第一个评论的人吧!"
}
} else {
CommentList {
comments: comments.clone(),
pending: ctx.pending_comments.read().clone(),
post_id,
}
CommentList { comments: comments.clone(), post_id }
}
}
}

View File

@ -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('&', "&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,2 +1 @@
pub mod delayed_loading;
pub mod comment_storage;