Support for comments
This commit is contained in:
48
.sqlx/query-3f9d64639b6536c0524c0241c25edc067fd53c4265ae2360215840b93584334c.json
generated
Normal file
48
.sqlx/query-3f9d64639b6536c0524c0241c25edc067fd53c4265ae2360215840b93584334c.json
generated
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT comment_id, post_id, author, content, published_at\n FROM comments\n WHERE post_id = $1\n ORDER BY published_at DESC\n LIMIT $2\n OFFSET $3\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "comment_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "post_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3f9d64639b6536c0524c0241c25edc067fd53c4265ae2360215840b93584334c"
|
||||||
|
}
|
||||||
17
.sqlx/query-767386497874bbf3988938d62112be9479d5dc7eb523246ac98816ff3e8d2754.json
generated
Normal file
17
.sqlx/query-767386497874bbf3988938d62112be9479d5dc7eb523246ac98816ff3e8d2754.json
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO comments (comment_id, post_id, author, content)\n VALUES ($1, $2, $3, $4)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "767386497874bbf3988938d62112be9479d5dc7eb523246ac98816ff3e8d2754"
|
||||||
|
}
|
||||||
22
.sqlx/query-bd08bf95dc1c8c0c7678bc509df7ce776e839846f29981e2e0bdfd382de9370f.json
generated
Normal file
22
.sqlx/query-bd08bf95dc1c8c0c7678bc509df7ce776e839846f29981e2e0bdfd382de9370f.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count(*) FROM comments WHERE post_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bd08bf95dc1c8c0c7678bc509df7ce776e839846f29981e2e0bdfd382de9370f"
|
||||||
|
}
|
||||||
2749
assets/css/main.css
2749
assets/css/main.css
File diff suppressed because one or more lines are too long
7
migrations/20251001165158_create_comments_table.sql
Normal file
7
migrations/20251001165158_create_comments_table.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE comments (
|
||||||
|
comment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID NOT NULL REFERENCES posts (post_id) ON DELETE CASCADE,
|
||||||
|
author TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
mod comment;
|
||||||
mod new_subscriber;
|
mod new_subscriber;
|
||||||
mod post;
|
mod post;
|
||||||
mod subscriber_email;
|
mod subscriber_email;
|
||||||
mod subscribers;
|
mod subscribers;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
|
pub use comment::CommentEntry;
|
||||||
pub use new_subscriber::NewSubscriber;
|
pub use new_subscriber::NewSubscriber;
|
||||||
pub use post::PostEntry;
|
pub use post::PostEntry;
|
||||||
pub use subscriber_email::SubscriberEmail;
|
pub use subscriber_email::SubscriberEmail;
|
||||||
|
|||||||
16
src/domain/comment.rs
Normal file
16
src/domain/comment.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct CommentEntry {
|
||||||
|
pub comment_id: Uuid,
|
||||||
|
pub post_id: Uuid,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub published_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommentEntry {
|
||||||
|
pub fn formatted_date(&self) -> String {
|
||||||
|
self.published_at.format("%B %d, %Y").to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod admin;
|
mod admin;
|
||||||
|
mod comments;
|
||||||
mod health_check;
|
mod health_check;
|
||||||
mod home;
|
mod home;
|
||||||
mod login;
|
mod login;
|
||||||
@@ -15,6 +16,7 @@ use axum::{
|
|||||||
http::{HeaderMap, request::Parts},
|
http::{HeaderMap, request::Parts},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
pub use comments::*;
|
||||||
pub use health_check::*;
|
pub use health_check::*;
|
||||||
pub use home::*;
|
pub use home::*;
|
||||||
pub use login::*;
|
pub use login::*;
|
||||||
|
|||||||
152
src/routes/comments.rs
Normal file
152
src/routes/comments.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
use crate::{
|
||||||
|
domain::CommentEntry,
|
||||||
|
routes::AppError,
|
||||||
|
startup::AppState,
|
||||||
|
templates::{CommentsList, HtmlTemplate, MessageTemplate},
|
||||||
|
};
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::{
|
||||||
|
Form,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct CommentPathParam {
|
||||||
|
post_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct CommentForm {
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))]
|
||||||
|
pub async fn post_comment(
|
||||||
|
Path(CommentPathParam { post_id }): Path<CommentPathParam>,
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
Form(form): Form<CommentForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
validate_form(&form)?;
|
||||||
|
let comment_id = insert_comment(&connection_pool, post_id, form)
|
||||||
|
.await
|
||||||
|
.context("Could not insert comment into database.")?;
|
||||||
|
tracing::info!("new comment with id {} has been inserted", comment_id);
|
||||||
|
let template = HtmlTemplate(MessageTemplate::success(
|
||||||
|
"Your comment has been posted.".into(),
|
||||||
|
));
|
||||||
|
Ok(template.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> {
|
||||||
|
if form.content.is_empty() {
|
||||||
|
anyhow::bail!("Comment content cannot be empty.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Inserting new comment in database", skip_all, fields(comment_id = tracing::field::Empty))]
|
||||||
|
async fn insert_comment(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
post_id: Uuid,
|
||||||
|
form: CommentForm,
|
||||||
|
) -> Result<Uuid, sqlx::Error> {
|
||||||
|
let author = form
|
||||||
|
.author
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.map(|s| s.trim().to_string());
|
||||||
|
let comment_id = Uuid::new_v4();
|
||||||
|
tracing::Span::current().record("comment_id", comment_id.to_string());
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
INSERT INTO comments (comment_id, post_id, author, content)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
",
|
||||||
|
comment_id,
|
||||||
|
post_id,
|
||||||
|
author,
|
||||||
|
form.content.trim()
|
||||||
|
)
|
||||||
|
.execute(connection_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(comment_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMENTS_PER_PAGE: i64 = 5;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct GetCommentsQueryParams {
|
||||||
|
page: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Fetching comments", skip(connection_pool))]
|
||||||
|
pub async fn get_comments(
|
||||||
|
Path(CommentPathParam { post_id }): Path<CommentPathParam>,
|
||||||
|
Query(GetCommentsQueryParams { page }): Query<GetCommentsQueryParams>,
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let comments = fetch_comments_page(&connection_pool, post_id, page)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch comments.")?;
|
||||||
|
let count = fetch_comments_count(&connection_pool, post_id)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch comments count")?;
|
||||||
|
let max_page = get_comments_page_count(count);
|
||||||
|
let template = HtmlTemplate(CommentsList {
|
||||||
|
comments,
|
||||||
|
current_page: page,
|
||||||
|
max_page,
|
||||||
|
});
|
||||||
|
Ok(template.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_comments_page(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
post_id: Uuid,
|
||||||
|
page: i64,
|
||||||
|
) -> Result<Vec<CommentEntry>, sqlx::Error> {
|
||||||
|
let offset = (page - 1) * COMMENTS_PER_PAGE;
|
||||||
|
let comments = sqlx::query_as!(
|
||||||
|
CommentEntry,
|
||||||
|
"
|
||||||
|
SELECT comment_id, post_id, author, content, published_at
|
||||||
|
FROM comments
|
||||||
|
WHERE post_id = $1
|
||||||
|
ORDER BY published_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
OFFSET $3
|
||||||
|
",
|
||||||
|
post_id,
|
||||||
|
COMMENTS_PER_PAGE,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(connection_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_comments_count(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
post_id: Uuid,
|
||||||
|
) -> Result<i64, sqlx::Error> {
|
||||||
|
let count = sqlx::query_scalar!("SELECT count(*) FROM comments WHERE post_id = $1", post_id)
|
||||||
|
.fetch_one(connection_pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_comments_page_count(count: i64) -> i64 {
|
||||||
|
let mut max_page = count.div_euclid(COMMENTS_PER_PAGE);
|
||||||
|
if count % COMMENTS_PER_PAGE > 0 {
|
||||||
|
max_page += 1;
|
||||||
|
}
|
||||||
|
max_page
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
domain::PostEntry,
|
domain::PostEntry,
|
||||||
routes::{AppError, Path, Query, not_found_html},
|
routes::{
|
||||||
|
AppError, Path, Query, fetch_comments_count, fetch_comments_page, get_comments_page_count,
|
||||||
|
not_found_html,
|
||||||
|
},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
|
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
|
||||||
};
|
};
|
||||||
@@ -89,7 +92,21 @@ pub async fn see_post(
|
|||||||
let post = post
|
let post = post
|
||||||
.to_html()
|
.to_html()
|
||||||
.context("Could not render markdown with extension.")?;
|
.context("Could not render markdown with extension.")?;
|
||||||
let template = HtmlTemplate(PostTemplate { post });
|
let current_page = 1;
|
||||||
|
let comments_count = fetch_comments_count(&connection_pool, post_id)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch comment count")?;
|
||||||
|
let max_page = get_comments_page_count(comments_count);
|
||||||
|
let comments = fetch_comments_page(&connection_pool, post_id, 1)
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch latest comments")?;
|
||||||
|
let template = HtmlTemplate(PostTemplate {
|
||||||
|
post,
|
||||||
|
comments,
|
||||||
|
current_page,
|
||||||
|
max_page,
|
||||||
|
comments_count,
|
||||||
|
});
|
||||||
Ok(template.into_response())
|
Ok(template.into_response())
|
||||||
} else {
|
} else {
|
||||||
Ok(not_found_html())
|
Ok(not_found_html())
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ pub fn app(
|
|||||||
.route("/posts", get(list_posts))
|
.route("/posts", get(list_posts))
|
||||||
.route("/posts/load_more", get(load_more))
|
.route("/posts/load_more", get(load_more))
|
||||||
.route("/posts/{post_id}", get(see_post))
|
.route("/posts/{post_id}", get(see_post))
|
||||||
|
.route(
|
||||||
|
"/posts/{post_id}/comments",
|
||||||
|
post(post_comment).get(get_comments),
|
||||||
|
)
|
||||||
.route("/users/{username}", get(user_profile))
|
.route("/users/{username}", get(user_profile))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
.nest("/admin", auth_routes)
|
.nest("/admin", auth_routes)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthenticatedUser,
|
authentication::AuthenticatedUser,
|
||||||
domain::{PostEntry, SubscriberEntry, UserEntry},
|
domain::{CommentEntry, PostEntry, SubscriberEntry, UserEntry},
|
||||||
routes::{AppError, DashboardStats},
|
routes::{AppError, DashboardStats},
|
||||||
};
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
@@ -88,6 +88,18 @@ pub struct PostListTemplate {
|
|||||||
#[template(path = "posts/page.html")]
|
#[template(path = "posts/page.html")]
|
||||||
pub struct PostTemplate {
|
pub struct PostTemplate {
|
||||||
pub post: PostEntry,
|
pub post: PostEntry,
|
||||||
|
pub comments: Vec<CommentEntry>,
|
||||||
|
pub current_page: i64,
|
||||||
|
pub max_page: i64,
|
||||||
|
pub comments_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "posts/comments/list.html", block = "comments")]
|
||||||
|
pub struct CommentsList {
|
||||||
|
pub comments: Vec<CommentEntry>,
|
||||||
|
pub current_page: i64,
|
||||||
|
pub max_page: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|||||||
30
templates/posts/comments/card.html
Normal file
30
templates/posts/comments/card.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center space-x-2 mb-1">
|
||||||
|
<span class="font-medium text-gray-900">
|
||||||
|
{% if let Some(name) = comment.author %}
|
||||||
|
{{ name }}
|
||||||
|
{% else %}
|
||||||
|
Anonymous
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400">•</span>
|
||||||
|
<time class="text-sm text-gray-500" datetime="{{ comment.published_at }}">
|
||||||
|
{{ comment.formatted_date() }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700 whitespace-pre-line">{{ comment.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
68
templates/posts/comments/list.html
Normal file
68
templates/posts/comments/list.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{%- import "macros.html" as macros -%}
|
||||||
|
<div class="border-t-2 border-gray-300 border-dashed pt-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-6">Comments ({{ comments_count }})</h2>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-6 mb-8 border border-gray-200">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Leave a comment</h3>
|
||||||
|
<form hx-post="/posts/{{ post.post_id }}/comments"
|
||||||
|
hx-target="#form-messages"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<input type="text"
|
||||||
|
name="author"
|
||||||
|
placeholder="Your name (optional)"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<textarea name="content"
|
||||||
|
rows="4"
|
||||||
|
required
|
||||||
|
placeholder="Write your comment..."
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-6 rounded-md transition-colors">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<div id="form-messages"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% block comments %}
|
||||||
|
{% if comments.is_empty() %}
|
||||||
|
<div id="comments-list" class="text-center py-8 text-gray-500">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<p>No comments yet. Be the first to comment!</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% let post_id = comments[0].post_id %}
|
||||||
|
<div id="comments-list" class="space-y-4">
|
||||||
|
{% for comment in comments %}
|
||||||
|
{% include "posts/comments/card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
<div id="load-more-comments" class="text-center mt-6">
|
||||||
|
{% if current_page < max_page %}
|
||||||
|
<div class="flex flex-col items-center space-y-6">
|
||||||
|
<button hx-get="/posts/{{ post_id }}/comments?page={{ current_page + 1 }}"
|
||||||
|
hx-target="#load-more-comments"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator="#comment-indicator"
|
||||||
|
class="text-center bg-gray-200 text-gray-700 hover:bg-gray-300 font-medium py-2 px-6 rounded-md transition-colors">
|
||||||
|
Load more comments
|
||||||
|
</button>
|
||||||
|
<span id="comment-indicator" class="htmx-indicator">
|
||||||
|
{% call macros::spinner(class="text-gray-300 w-6 h-6", highlight_class="text-gray-700", size=24) %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-600">No more comments. Check back later for more!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="prose-compact">{{ post.content | safe }}</div>
|
<div class="prose-compact">{{ post.content | safe }}</div>
|
||||||
</article>
|
</article>
|
||||||
|
<div class="mt-12">{% include "posts/comments/list.html" %}</div>
|
||||||
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
||||||
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
||||||
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user