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:
107
Cargo.lock
generated
107
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
109
src/handlers/public.rs
Normal 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
|
||||
}
|
||||
}
|
||||
21
src/main.rs
21
src/main.rs
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
46
templates/post.html
Normal 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>
|
||||
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user