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:
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"
+ >▲</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"
- >▲</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>