commit fb434d9dc0869e369758fd5a54dd6127bca91c44
parent dd198be281d7cff5b02d8995ccaa9f0aa010f7be
Author: Silas Brack <silasbrack@gmail.com>
Date: Mon, 8 Jun 2026 22:33:20 +0200
feat: swap comments via fetch instead of full page reload
On SSE event, fetch /story/{id}/comments HTML fragment and swap
the comments section innerHTML instead of reloading the page.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 60 insertions(+), 4 deletions(-)
diff --git a/src/handlers/comment.rs b/src/handlers/comment.rs
@@ -7,10 +7,11 @@ use axum::{
use axum_extra::extract::cookie::CookieJar;
use serde::Deserialize;
+use crate::db::CommentWithMeta;
use crate::error::AppError;
use crate::handlers::auth::get_tokens_from_cookies;
use crate::state::AppState;
-use crate::templates::{ApplicationTemplate, ReplyFormTemplate};
+use crate::templates::{ApplicationTemplate, CommentsTemplate, ReplyFormTemplate};
use crate::trailbase;
#[derive(Debug, Deserialize)]
@@ -56,6 +57,50 @@ pub async fn create_comment(
Ok(Redirect::to(&format!("/story/{}", story_id)))
}
+/// Get comments HTML fragment for realtime updates
+pub async fn get_comments(
+ State(state): State<AppState>,
+ jar: CookieJar,
+ Path(story_id): Path<i64>,
+) -> Result<Html<String>, AppError> {
+ let tokens = get_tokens_from_cookies(&jar);
+ let client = match &tokens {
+ Some(t) => trailbase::client_with_tokens(&state.trailbase_url, t)?,
+ None => trailbase::new_client(&state.trailbase_url)?,
+ };
+
+ let comments = trailbase::get_comments_for_story(&client, story_id).await?;
+
+ let comment_ids: Vec<i64> = comments.iter().map(|c| c.id).collect();
+ let voted_comment_ids = if tokens.is_some() && !comment_ids.is_empty() {
+ trailbase::get_user_votes(&client, trailbase::TARGET_TYPE_COMMENT, &comment_ids)
+ .await
+ .inspect_err(|e| tracing::error!("Failed to get comment votes: {}", e))
+ .unwrap_or_default()
+ } else {
+ vec![]
+ };
+
+ let mut enriched = Vec::with_capacity(comments.len());
+ for comment in comments {
+ let author = trailbase::get_user_profile_by_id(&client, &comment.created_by)
+ .await
+ .inspect_err(|e| tracing::error!("Failed to get profile for {}: {}", comment.created_by, e))
+ .ok()
+ .flatten()
+ .map(|p| p.username);
+ let time_ago = trailbase::time_ago(&comment.created_at);
+ let voted = voted_comment_ids.contains(&comment.id);
+ enriched.push(CommentWithMeta::from_comment(comment, author, time_ago, voted));
+ }
+
+ let template = CommentsTemplate {
+ comments: enriched,
+ is_logged_in: tokens.is_some(),
+ };
+ Ok(Html(template.render()?))
+}
+
/// Show reply form for a comment
pub async fn show_reply_form(
State(state): State<AppState>,
diff --git a/src/routes.rs b/src/routes.rs
@@ -70,6 +70,7 @@ pub fn create_router(state: AppState) -> Router {
.route("/story/{id}/edit", post(handlers::edit_story))
// Comments
+ .route("/story/{id}/comments", get(handlers::get_comments))
.route("/story/{id}/comment", post(handlers::create_comment))
.route("/comment/{id}/reply", get(handlers::show_reply_form))
.route("/comment/{id}/reply", post(handlers::submit_reply))
diff --git a/src/templates.rs b/src/templates.rs
@@ -31,6 +31,13 @@ pub struct StoryTemplate {
}
#[derive(Template)]
+#[template(path = "comments.html")]
+pub struct CommentsTemplate {
+ pub comments: Vec<CommentWithMeta>,
+ pub is_logged_in: bool,
+}
+
+#[derive(Template)]
#[template(path = "submit.html")]
pub struct SubmitTemplate {
pub error: Option<String>,
diff --git a/templates/story.html b/templates/story.html
@@ -87,9 +87,12 @@
<script>
(function() {
var evtSource = new EventSource('/api/records/v1/comment/subscribe/*?filter[story_id]={{ story.id }}');
- evtSource.onmessage = function(event) {
- window.location.reload();
- };
+ evtSource.onmessage = function() {
+ fetch('/story/{{ story.id }}/comments')
+ .then(function(r) { return r.text(); })
+ .then(function(html) {
+ document.getElementById('comments-section').innerHTML = html;
+ });
})();
</script>
</div>