simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 9e1ceeba2fb27204e0970de395a5b3bc1d7de9e5
parent cd4097632d1927ba9a83fb9762f49b8e33c2ad4a
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sun,  7 Jun 2026 21:48:52 +0200

feat: add realtime comment polling via HTMX

Comments section polls /story/{id}/comments every 10s via HTMX,
so new comments from other users appear without page refresh.
Extracted comments into a reusable template partial.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Msrc/handlers/comment.rs | 46+++++++++++++++++++++++++++++++++++++++++++++-
Msrc/routes.rs | 1+
Msrc/templates.rs | 7+++++++
Atemplates/comments.html | 32++++++++++++++++++++++++++++++++
Mtemplates/story.html | 39++++++---------------------------------
5 files changed, 91 insertions(+), 34 deletions(-)

diff --git a/src/handlers/comment.rs b/src/handlers/comment.rs @@ -10,7 +10,8 @@ use serde::Deserialize; use crate::error::AppError; use crate::handlers::auth::get_tokens_from_cookies; use crate::state::AppState; -use crate::templates::{ApplicationTemplate, ReplyFormTemplate}; +use crate::db::CommentWithMeta; +use crate::templates::{ApplicationTemplate, CommentsTemplate, ReplyFormTemplate}; use crate::trailbase; #[derive(Debug, Deserialize)] @@ -56,6 +57,49 @@ pub async fn create_comment( Ok(Redirect::to(&format!("/story/{}", story_id))) } +/// Get comments HTML fragment for HTMX polling +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 + .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 + .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 is_logged_in = tokens.is_some(); + let template = CommentsTemplate { + comments: enriched, + is_logged_in, + }; + 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 @@ -72,6 +72,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/comments.html b/templates/comments.html @@ -0,0 +1,32 @@ +{% for comment in comments %} +<div class="comment" id="comment-{{ comment.id }}" style="margin-left: {{ comment.indent_px }}px;"> + <div class="comment-header"> + <span id="vote-comment-{{ comment.id }}"> + {% if is_logged_in %} + <button + class="vote-btn comment-vote{% if comment.user_voted %} voted{% endif %}" + hx-post="/vote/comment/{{ comment.id }}" + hx-target="#vote-comment-{{ comment.id }}" + hx-swap="innerHTML" + >&#9650;</button> + {% endif %} + </span> + {% match comment.author_username %} + {% when Some with (username) %} + <a href="/user/{{ username }}" class="comment-author">{{ username }}</a> + {% when None %} + <span class="comment-author">anonymous</span> + {% endmatch %} + <span class="comment-score" id="score-comment-{{ comment.id }}">{{ comment.score }} point{% if comment.score != 1 %}s{% endif %}</span> + <span class="comment-time">{{ comment.time_ago }}</span> + </div> + <div class="comment-text"> + {{ comment.text }} + </div> + <div class="comment-actions"> + {% if is_logged_in %} + <a href="/comment/{{ comment.id }}/reply" class="comment-reply">reply</a> + {% endif %} + </div> +</div> +{% endfor %} diff --git a/templates/story.html b/templates/story.html @@ -82,38 +82,11 @@ <p class="login-prompt"><a href="/login">Log in</a> to comment.</p> {% endif %} - <section class="comments"> - {% for comment in comments %} - <div class="comment" id="comment-{{ comment.id }}" style="margin-left: {{ comment.indent_px }}px;"> - <div class="comment-header"> - <span id="vote-comment-{{ comment.id }}"> - {% if is_logged_in %} - <button - class="vote-btn comment-vote{% if comment.user_voted %} voted{% endif %}" - hx-post="/vote/comment/{{ comment.id }}" - hx-target="#vote-comment-{{ comment.id }}" - hx-swap="innerHTML" - >&#9650;</button> - {% endif %} - </span> - {% match comment.author_username %} - {% when Some with (username) %} - <a href="/user/{{ username }}" class="comment-author">{{ username }}</a> - {% when None %} - <span class="comment-author">anonymous</span> - {% endmatch %} - <span class="comment-score" id="score-comment-{{ comment.id }}">{{ comment.score }} point{% if comment.score != 1 %}s{% endif %}</span> - <span class="comment-time">{{ comment.time_ago }}</span> - </div> - <div class="comment-text"> - {{ comment.text }} - </div> - <div class="comment-actions"> - {% if is_logged_in %} - <a href="/comment/{{ comment.id }}/reply" class="comment-reply">reply</a> - {% endif %} - </div> - </div> - {% endfor %} + <section class="comments" + hx-get="/story/{{ story.id }}/comments" + hx-trigger="every 10s" + hx-swap="innerHTML" + > + {% include "comments.html" %} </section> </div>