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 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::*;
|
||||||
|
|||||||
@@ -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
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("/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<_>| {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
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