feat: Implement public blog post listing and individual post viewing with new public handlers and templates, adding chrono for date formatting.

This commit is contained in:
2026-03-03 17:11:26 +00:00
parent ef068f7dfa
commit 57f2610164
10 changed files with 303 additions and 24 deletions

107
Cargo.lock generated
View File

@@ -17,6 +17,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -232,6 +241,7 @@ dependencies = [
"axum-extra",
"base64",
"bcrypt",
"chrono",
"dotenvy",
"rand 0.10.0",
"serde",
@@ -298,6 +308,20 @@ dependencies = [
"rand_core 0.10.0",
]
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -334,6 +358,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -774,6 +804,30 @@ dependencies = [
"tower-service",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -2213,12 +2267,65 @@ dependencies = [
"wasite",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"

View File

@@ -10,6 +10,7 @@ axum = "0.8.8"
axum-extra = { version = "0.12.5", features = ["cookie"] }
base64 = "0.22.1"
bcrypt = "0.18.0"
chrono = { version = "0.4.44", features = ["serde"] }
dotenvy = "0.15.7"
rand = "0.10.0"
serde = { version = "1.0.228", features = ["derive"] }

View File

@@ -3,7 +3,7 @@ use axum::{
response::{IntoResponse, Response},
};
pub struct AppError(anyhow::Error);
pub struct AppError(pub anyhow::Error);
impl IntoResponse for AppError {
fn into_response(self) -> Response {

View File

@@ -5,6 +5,7 @@ use std::sync::Arc;
pub mod admin;
pub mod auth;
pub mod posts;
pub mod public;
pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
Router::new()

109
src/handlers/public.rs Normal file
View File

@@ -0,0 +1,109 @@
use askama::Template;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
routing::get,
Router,
};
use std::sync::Arc;
use crate::models::BlogPost;
use crate::utils::HtmlTemplate;
use crate::AppState;
use crate::error::AppError;
use anyhow::Context;
#[derive(Template)]
#[template(path = "index.html")]
pub struct IndexTemplate {
pub posts: Vec<(BlogPost, String)>, // Post and Author Username
}
#[derive(Template)]
#[template(path = "post.html")]
pub struct PostTemplate {
pub post: BlogPost,
pub author_username: String,
}
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(index))
.route("/post/{id}", get(view_post))
}
pub async fn index(
State(state): State<Arc<AppState>>,
) -> Result<Response, AppError> {
// Fetch public posts and their author usernames
let posts = sqlx::query!(
r#"
SELECT p.*, u.username as author_username
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.visibility = 'public'
ORDER BY p.created_at DESC
"#
)
.fetch_all(&state.db)
.await
.context("Failed to fetch public posts from database")?
.into_iter()
.map(|row| {
let post = BlogPost {
id: row.id,
author_id: row.author_id,
title: row.title,
content: row.content,
tags: row.tags,
categories: row.categories,
visibility: row.visibility,
password: row.password,
created_at: row.created_at,
updated_at: row.updated_at,
};
(post, row.author_username)
})
.collect();
Ok(HtmlTemplate(IndexTemplate { posts }).into_response())
}
pub async fn view_post(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> Result<Response, AppError> {
// Fetch individual post and author username regardless of visibility
let post_result = sqlx::query!(
r#"
SELECT p.*, u.username as author_username
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.id = ?
"#,
id
)
.fetch_optional(&state.db)
.await
.context("Failed to fetch post from database")?
.map(|row| {
let post = BlogPost {
id: row.id,
author_id: row.author_id,
title: row.title,
content: row.content,
tags: row.tags,
categories: row.categories,
visibility: row.visibility,
password: row.password,
created_at: row.created_at,
updated_at: row.updated_at,
};
(post, row.author_username)
});
match post_result {
Some((post, author_username)) => Ok(HtmlTemplate(PostTemplate { post, author_username }).into_response()),
None => Err(AppError(anyhow::anyhow!("Post not found"))), // In a real app we'd want a 404 page
}
}

View File

@@ -1,10 +1,4 @@
use askama::Template;
use axum::{
extract::State,
response::IntoResponse,
routing::get,
Router,
};
use axum::Router;
use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, SqlitePool};
use std::str::FromStr;
use std::sync::Arc;
@@ -22,18 +16,7 @@ pub struct AppState {
pub db: SqlitePool,
}
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
title: String,
}
async fn index(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let template = IndexTemplate {
title: "Coming Soon".to_string(),
};
crate::utils::HtmlTemplate(template)
}
#[tokio::main]
async fn main() {
@@ -115,7 +98,7 @@ async fn main() {
let app_state = Arc::new(AppState { db: db_pool });
let app = Router::new()
.route("/", get(index))
.merge(handlers::public::router())
.nest("/__dungeon", handlers::router(&app_state)) // I'll create a single `handlers::router`
.with_state(app_state.clone())
.layer(tower_http::trace::TraceLayer::new_for_http());

View File

@@ -1,3 +1,4 @@
use chrono::DateTime;
use serde::{Deserialize, Serialize};
use sqlx::prelude::FromRow;
@@ -95,4 +96,14 @@ impl BlogPost {
pub fn visibility(&self) -> Visibility {
self.visibility.parse().unwrap_or(Visibility::Public)
}
pub fn formatted_created_at(&self) -> String {
let datetime = DateTime::from_timestamp(self.created_at, 0).unwrap_or_default();
datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}
pub fn formatted_updated_at(&self) -> String {
let datetime = DateTime::from_timestamp(self.updated_at, 0).unwrap_or_default();
datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
<title>blog</title>
<style>
* {
font-family: monospace !important;
@@ -12,8 +12,29 @@
</head>
<body bgcolor="#FFFFFF" text="#000000" link="#0000EE" vlink="#551A8B" alink="#FF0000">
<h1>{{ title }}</h1>
<p>We are working hard to build something amazing.</p>
<h1>blog</h1>
<p>Welcome to my corner of the web.</p>
<hr size="1">
<h2>Public Posts</h2>
{% if posts.is_empty() %}
<p>No public posts available yet.</p>
{% else %}
{% for (post, author) in posts %}
<p>
<b><a href="/post/{{ post.id }}">{{ post.title }}</a></b><br>
<i>Posted by {{ author }} on {{ post.formatted_created_at() }}</i><br>
{% if !post.categories.is_empty() %}
Categories: {{ post.categories }}<br>
{% endif %}
{% if !post.tags.is_empty() %}
Tags: {{ post.tags }}<br>
{% endif %}
</p>
<br>
{% endfor %}
{% endif %}
</body>
</html>

46
templates/post.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ post.title }} - Blog</title>
<style>
* {
font-family: monospace !important;
}
</style>
</head>
<body bgcolor="#FFFFFF" text="#000000" link="#0000EE" vlink="#551A8B" alink="#FF0000">
<p><a href="/">[Back to Home]</a></p>
<hr size="1">
<h1>{{ post.title }}</h1>
<p><i>Posted by {{ author_username }} on {{ post.formatted_created_at() }}</i></p>
{% if !post.categories.is_empty() %}
<p>Categories: {{ post.categories }}</p>
{% endif %}
{% if !post.tags.is_empty() %}
<p>Tags: {{ post.tags }}</p>
{% endif %}
{% if post.visibility == "password_protected" %}
<p><b>[This post is password protected. In a full implementation, you'd enter a password here.]</b></p>
<hr size="1">
{% else if post.visibility == "private" %}
<p><b>[This post is private and only visible to authorized users.]</b></p>
<hr size="1">
{% else %}
<hr size="1">
<!-- Assuming content is plain text with newlines -->
<p>
<pre>{{ post.content|safe }}</pre>
</p>
<hr size="1">
{% endif %}
<p><a href="/">[Back to Home]</a></p>
</body>
</html>

View File

@@ -27,7 +27,7 @@
<td>{{ post.id }}</td>
<td>{{ post.title }}</td>
<td>{{ post.visibility }}</td>
<td>{{ post.created_at }}</td>
<td>{{ post.formatted_created_at() }}</td>
<td>
<a href="/__dungeon/posts/edit/{{ post.id }}">Edit</a> |
<form method="POST" action="/__dungeon/posts/delete/{{ post.id }}" style="display:inline;">