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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -232,6 +241,7 @@ dependencies = [
|
|||||||
"axum-extra",
|
"axum-extra",
|
||||||
"base64",
|
"base64",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -298,6 +308,20 @@ dependencies = [
|
|||||||
"rand_core 0.10.0",
|
"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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@@ -334,6 +358,12 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -774,6 +804,30 @@ dependencies = [
|
|||||||
"tower-service",
|
"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]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -2213,12 +2267,65 @@ dependencies = [
|
|||||||
"wasite",
|
"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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.48.0"
|
version = "0.48.0"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ axum = "0.8.8"
|
|||||||
axum-extra = { version = "0.12.5", features = ["cookie"] }
|
axum-extra = { version = "0.12.5", features = ["cookie"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bcrypt = "0.18.0"
|
bcrypt = "0.18.0"
|
||||||
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
rand = "0.10.0"
|
rand = "0.10.0"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct AppError(anyhow::Error);
|
pub struct AppError(pub anyhow::Error);
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::Arc;
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
|
pub mod public;
|
||||||
|
|
||||||
pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
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::Router;
|
||||||
use axum::{
|
|
||||||
extract::State,
|
|
||||||
response::IntoResponse,
|
|
||||||
routing::get,
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, SqlitePool};
|
use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, SqlitePool};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -22,18 +16,7 @@ pub struct AppState {
|
|||||||
pub db: SqlitePool,
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
@@ -115,7 +98,7 @@ async fn main() {
|
|||||||
let app_state = Arc::new(AppState { db: db_pool });
|
let app_state = Arc::new(AppState { db: db_pool });
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(index))
|
.merge(handlers::public::router())
|
||||||
.nest("/__dungeon", handlers::router(&app_state)) // I'll create a single `handlers::router`
|
.nest("/__dungeon", handlers::router(&app_state)) // I'll create a single `handlers::router`
|
||||||
.with_state(app_state.clone())
|
.with_state(app_state.clone())
|
||||||
.layer(tower_http::trace::TraceLayer::new_for_http());
|
.layer(tower_http::trace::TraceLayer::new_for_http());
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use chrono::DateTime;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::prelude::FromRow;
|
use sqlx::prelude::FromRow;
|
||||||
|
|
||||||
@@ -95,4 +96,14 @@ impl BlogPost {
|
|||||||
pub fn visibility(&self) -> Visibility {
|
pub fn visibility(&self) -> Visibility {
|
||||||
self.visibility.parse().unwrap_or(Visibility::Public)
|
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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{{ title }}</title>
|
<title>blog</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
font-family: monospace !important;
|
font-family: monospace !important;
|
||||||
@@ -12,8 +12,29 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body bgcolor="#FFFFFF" text="#000000" link="#0000EE" vlink="#551A8B" alink="#FF0000">
|
<body bgcolor="#FFFFFF" text="#000000" link="#0000EE" vlink="#551A8B" alink="#FF0000">
|
||||||
<h1>{{ title }}</h1>
|
<h1>blog</h1>
|
||||||
<p>We are working hard to build something amazing.</p>
|
<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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</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.id }}</td>
|
||||||
<td>{{ post.title }}</td>
|
<td>{{ post.title }}</td>
|
||||||
<td>{{ post.visibility }}</td>
|
<td>{{ post.visibility }}</td>
|
||||||
<td>{{ post.created_at }}</td>
|
<td>{{ post.formatted_created_at() }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/__dungeon/posts/edit/{{ post.id }}">Edit</a> |
|
<a href="/__dungeon/posts/edit/{{ post.id }}">Edit</a> |
|
||||||
<form method="POST" action="/__dungeon/posts/delete/{{ post.id }}" style="display:inline;">
|
<form method="POST" action="/__dungeon/posts/delete/{{ post.id }}" style="display:inline;">
|
||||||
|
|||||||
Reference in New Issue
Block a user