diff --git a/Cargo.lock b/Cargo.lock index c18b73c..0e37d6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 0729bed..b99936e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/error.rs b/src/error.rs index 8150884..705d3b6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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 { diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 0b55285..2a0b96b 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -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) -> Router> { Router::new() diff --git a/src/handlers/public.rs b/src/handlers/public.rs new file mode 100644 index 0000000..5d6f247 --- /dev/null +++ b/src/handlers/public.rs @@ -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> { + Router::new() + .route("/", get(index)) + .route("/post/{id}", get(view_post)) +} + +pub async fn index( + State(state): State>, +) -> Result { + // 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>, + Path(id): Path, +) -> Result { + // 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 + } +} diff --git a/src/main.rs b/src/main.rs index 688e3de..867523d 100644 --- a/src/main.rs +++ b/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>) -> 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()); diff --git a/src/models.rs b/src/models.rs index 44bd3d7..e66cb13 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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() + } } diff --git a/templates/index.html b/templates/index.html index 4a17976..365cace 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,7 +3,7 @@ - {{ title }} + blog + + + +

[Back to Home]

+
+ +

{{ post.title }}

+

Posted by {{ author_username }} on {{ post.formatted_created_at() }}

+ + {% if !post.categories.is_empty() %} +

Categories: {{ post.categories }}

+ {% endif %} + {% if !post.tags.is_empty() %} +

Tags: {{ post.tags }}

+ {% endif %} + + {% if post.visibility == "password_protected" %} +

[This post is password protected. In a full implementation, you'd enter a password here.]

+
+ {% else if post.visibility == "private" %} +

[This post is private and only visible to authorized users.]

+
+ {% else %} +
+ +

+

{{ post.content|safe }}
+

+
+ {% endif %} + +

[Back to Home]

+ + + \ No newline at end of file diff --git a/templates/posts_list.html b/templates/posts_list.html index c3c4fd8..2909aa4 100644 --- a/templates/posts_list.html +++ b/templates/posts_list.html @@ -27,7 +27,7 @@ {{ post.id }} {{ post.title }} {{ post.visibility }} - {{ post.created_at }} + {{ post.formatted_created_at() }} Edit |