Posts dedicated page with cards linking to specific post
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@ mod admin;
|
||||
mod health_check;
|
||||
mod home;
|
||||
mod login;
|
||||
mod posts;
|
||||
mod subscriptions;
|
||||
mod subscriptions_confirm;
|
||||
|
||||
@@ -9,5 +10,6 @@ pub use admin::*;
|
||||
pub use health_check::*;
|
||||
pub use home::*;
|
||||
pub use login::*;
|
||||
pub use posts::*;
|
||||
pub use subscriptions::*;
|
||||
pub use subscriptions_confirm::*;
|
||||
|
||||
@@ -9,6 +9,7 @@ use askama::Template;
|
||||
use axum::{
|
||||
Form, Json,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum::{http::StatusCode, response::Redirect};
|
||||
@@ -114,11 +115,9 @@ pub async fn post_login(
|
||||
.await
|
||||
.map_err(|e| LoginError::UnexpectedError(e.into()))?;
|
||||
|
||||
let mut response = Redirect::to("/admin/dashboard").into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
|
||||
Ok(response)
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
|
||||
Ok((StatusCode::OK, headers).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
103
src/routes/posts.rs
Normal file
103
src/routes/posts.rs
Normal 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
|
||||
}
|
||||
@@ -129,6 +129,8 @@ pub fn app(
|
||||
.route("/health_check", get(health_check))
|
||||
.route("/subscriptions", post(subscribe))
|
||||
.route("/subscriptions/confirm", get(confirm))
|
||||
.route("/posts", get(list_posts))
|
||||
.route("/posts/{post_id}", get(see_post))
|
||||
.nest("/admin", admin_routes)
|
||||
.layer(
|
||||
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
<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="flex justify-between items-center h-16">
|
||||
<div class="flex-shrink-0">
|
||||
<a href="/" class="hover:opacity-80 transition-opacity">
|
||||
<h1 class="text-xl font-bold text-gray-900">
|
||||
<span class="text-blue-600">zero2prod</span>
|
||||
</h1>
|
||||
<nav class="flex items-center space-x-2">
|
||||
<a href="/"
|
||||
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">
|
||||
Home
|
||||
</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>
|
||||
<a href="/admin/dashboard"
|
||||
hx-boost="true"
|
||||
@@ -47,7 +50,7 @@
|
||||
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
|
||||
target="_blank"
|
||||
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"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-4xl font-bold mb-4">zero2prod</h1>
|
||||
<p class="text-xl text-blue-100 mb-6">
|
||||
Welcome to our newsletter! Stay updated on our latest projects and
|
||||
thoughts. Unsubscribe at any time.
|
||||
Welcome to my blog! Stay updated on my latest projects and
|
||||
thoughts. Subscribe (and unsubscribe) at any time.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<a href="#newsletter-signup"
|
||||
@@ -73,7 +73,7 @@
|
||||
class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<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"
|
||||
hx-target="#subscribe-messages"
|
||||
hx-swap="innerHTML"
|
||||
|
||||
@@ -1 +1,13 @@
|
||||
@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
48
templates/post.html
Normal 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
75
templates/posts.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user