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")]
This commit is contained in:
parent
7b3d894e96
commit
fccd4c05ff
188
src/hooks/comment_storage.rs
Normal file
188
src/hooks/comment_storage.rs
Normal file
@ -0,0 +1,188 @@
|
||||
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)]
|
||||
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_iso() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn is_expired(stored_at: &str) -> bool {
|
||||
let Ok(dt) = DateTime::parse_from_rfc3339(stored_at) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(now) = DateTime::parse_from_rfc3339(&now_iso()) else {
|
||||
return false;
|
||||
};
|
||||
(now - dt).num_days() > TTL_DAYS
|
||||
}
|
||||
|
||||
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 non_expired: Vec<PendingComment> = comments
|
||||
.into_iter()
|
||||
.filter(|c| !is_expired(&c.stored_at))
|
||||
.collect();
|
||||
|
||||
if !non_expired.is_empty() {
|
||||
map.insert(key, non_expired.clone());
|
||||
}
|
||||
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 fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
pub fn render_pending_content(md: &str) -> String {
|
||||
let escaped = escape_html(md);
|
||||
escaped.replace('\n', "<br>")
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
pub mod delayed_loading;
|
||||
pub mod comment_storage;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user