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 delayed_loading;
|
||||||
|
pub mod comment_storage;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user