Posts dedicated page with cards linking to specific post

This commit is contained in:
Alphonse Paix
2025-09-19 01:04:10 +02:00
parent 71d4872878
commit 95c4d3fdd0
10 changed files with 260 additions and 16 deletions

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,7 @@ mod admin;
mod health_check; mod health_check;
mod home; mod home;
mod login; mod login;
mod posts;
mod subscriptions; mod subscriptions;
mod subscriptions_confirm; mod subscriptions_confirm;
@@ -9,5 +10,6 @@ pub use admin::*;
pub use health_check::*; pub use health_check::*;
pub use home::*; pub use home::*;
pub use login::*; pub use login::*;
pub use posts::*;
pub use subscriptions::*; pub use subscriptions::*;
pub use subscriptions_confirm::*; pub use subscriptions_confirm::*;

View File

@@ -9,6 +9,7 @@ use askama::Template;
use axum::{ use axum::{
Form, Json, Form, Json,
extract::State, extract::State,
http::HeaderMap,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum::{http::StatusCode, response::Redirect}; use axum::{http::StatusCode, response::Redirect};
@@ -114,11 +115,9 @@ pub async fn post_login(
.await .await
.map_err(|e| LoginError::UnexpectedError(e.into()))?; .map_err(|e| LoginError::UnexpectedError(e.into()))?;
let mut response = Redirect::to("/admin/dashboard").into_response(); let mut headers = HeaderMap::new();
response headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
.headers_mut() Ok((StatusCode::OK, headers).into_response())
.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
Ok(response)
} }
} }
} }

103
src/routes/posts.rs Normal file
View File

@@ -0,0 +1,103 @@
use crate::startup::AppState;
use askama::Template;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Response},
};
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use sqlx::PgPool;
use uuid::Uuid;
struct PostEntry {
post_id: Uuid,
author: Option<String>,
title: String,
content: String,
published_at: DateTime<Utc>,
}
impl PostEntry {
#[allow(dead_code)]
fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string()
}
}
#[derive(Template)]
#[template(path = "../templates/posts.html")]
struct PostsTemplate {
posts: Vec<PostEntry>,
}
#[derive(Template)]
#[template(path = "../templates/post.html")]
struct PostTemplate {
post: PostEntry,
}
pub async fn list_posts(
State(AppState {
connection_pool, ..
}): State<AppState>,
) -> Response {
match get_latest_posts(&connection_pool, 5).await {
Err(e) => {
tracing::error!("Could not fetch latest posts: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Ok(posts) => {
let template = PostsTemplate { posts };
Html(template.render().unwrap()).into_response()
}
}
}
async fn get_latest_posts(connection_pool: &PgPool, n: i64) -> Result<Vec<PostEntry>, sqlx::Error> {
sqlx::query_as!(
PostEntry,
r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id
ORDER BY p.published_at DESC
LIMIT $1
"#,
n
)
.fetch_all(connection_pool)
.await
}
pub async fn see_post(
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(post_id): Path<Uuid>,
) -> Response {
match get_post(&connection_pool, post_id).await {
Err(e) => {
tracing::error!("Could not fetch post #{}: {}", post_id, e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Ok(post) => {
let template = PostTemplate { post };
Html(template.render().unwrap()).into_response()
}
}
}
async fn get_post(connection_pool: &PgPool, post_id: Uuid) -> Result<PostEntry, sqlx::Error> {
sqlx::query_as!(
PostEntry,
r#"
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id
WHERE p.post_id = $1
"#,
post_id
)
.fetch_one(connection_pool)
.await
}

View File

@@ -129,6 +129,8 @@ pub fn app(
.route("/health_check", get(health_check)) .route("/health_check", get(health_check))
.route("/subscriptions", post(subscribe)) .route("/subscriptions", post(subscribe))
.route("/subscriptions/confirm", get(confirm)) .route("/subscriptions/confirm", get(confirm))
.route("/posts", get(list_posts))
.route("/posts/{post_id}", get(see_post))
.nest("/admin", admin_routes) .nest("/admin", admin_routes)
.layer( .layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {

View File

@@ -15,13 +15,16 @@
<header class="bg-white shadow-sm border-b border-gray-200 top-0 z-40"> <header class="bg-white shadow-sm border-b border-gray-200 top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<div class="flex-shrink-0"> <nav class="flex items-center space-x-2">
<a href="/" class="hover:opacity-80 transition-opacity"> <a href="/"
<h1 class="text-xl font-bold text-gray-900"> class="flex items-center text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-3 py-2 rounded-md text-sm font-medium transition-colors">
<span class="text-blue-600">zero2prod</span> Home
</h1>
</a> </a>
</div> <a href="/posts"
class="flex items-center text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Posts
</a>
</nav>
<nav> <nav>
<a href="/admin/dashboard" <a href="/admin/dashboard"
hx-boost="true" hx-boost="true"
@@ -47,7 +50,7 @@
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod" <a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
target="_blank" target="_blank"
class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center"> class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center">
Code repository Gitea
<svg class="ml-1 h-3 w-3" <svg class="ml-1 h-3 w-3"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@@ -6,8 +6,8 @@
<div class="max-w-3xl"> <div class="max-w-3xl">
<h1 class="text-4xl font-bold mb-4">zero2prod</h1> <h1 class="text-4xl font-bold mb-4">zero2prod</h1>
<p class="text-xl text-blue-100 mb-6"> <p class="text-xl text-blue-100 mb-6">
Welcome to our newsletter! Stay updated on our latest projects and Welcome to my blog! Stay updated on my latest projects and
thoughts. Unsubscribe at any time. thoughts. Subscribe (and unsubscribe) at any time.
</p> </p>
<div class="flex flex-col sm:flex-row gap-4"> <div class="flex flex-col sm:flex-row gap-4">
<a href="#newsletter-signup" <a href="#newsletter-signup"
@@ -73,7 +73,7 @@
class="bg-white rounded-lg shadow-md p-8 border border-gray-200"> class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<div class="max-w-2xl mx-auto text-center"> <div class="max-w-2xl mx-auto text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2> <h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2>
<p class="text-gray-600 mb-6">Subscribe to our newsletter to get the latest updates.</p> <p class="text-gray-600 mb-6">Subscribe to my newsletter to get the latest updates.</p>
<form hx-post="/subscriptions" <form hx-post="/subscriptions"
hx-target="#subscribe-messages" hx-target="#subscribe-messages"
hx-swap="innerHTML" hx-swap="innerHTML"

View File

@@ -1 +1,13 @@
@import "tailwindcss"; @import "tailwindcss";
@layer utilities {
.font-inter {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
}
@layer base {
body {
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
}

48
templates/post.html Normal file
View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - zero2prod{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<article class="bg-white rounded-lg shadow-md border border-gray-200">
<header class="px-8 pt-8 pb-6 border-b border-gray-100">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2">
<svg class="w-4 h-4 text-blue-600"
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>
<span class="font-medium">{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
</time>
</div>
</div>
</div>
</header>
<div class="px-8 py-8">
<div class="prose prose-lg prose-blue max-w-none">{{ post.content | safe }}</div>
</div>
</article>
<div class="mt-12 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>
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
<a href="/#newsletter-signup"
class="inline-block bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors">
Subscribe
</a>
</div>
</div>
{% endblock %}

75
templates/posts.html Normal file
View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Posts - zero2prod{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Posts</h1>
<p class="mt-2 text-gray-600">Insights on Rust backend development, performance tips, and production stories.</p>
</div>
{% if posts.is_empty() %}
<div class="bg-white rounded-lg shadow-md p-8 border border-gray-200 text-center">
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts yet</h3>
<p class="text-gray-600">Check back later for new content!</p>
</div>
{% else %}
<div class="space-y-6">
{% for post in posts %}
<article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">
<a href="/posts/{{ post.post_id }}"
class="block p-6 hover:bg-gray-50 transition-colors duration-200">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2>
<div class="flex items-center text-sm text-gray-500">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
</time>
</div>
<span class="mx-2"></span>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1"
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>
<span>{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
</div>
</div>
</div>
<div class="flex-shrink-0 ml-4">
<svg class="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</a>
</article>
{% endfor %}
</div>
<div class="mt-8 text-center">
<button class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-6 rounded-md transition-colors">
Load more
</button>
</div>
{% endif %}
</div>
{% endblock %}