Askama + htmx for frontend
Server-side rendering with htmx and Tailwind CSS for the styling
This commit is contained in:
52
Cargo.lock
generated
52
Cargo.lock
generated
@@ -90,6 +90,48 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "askama"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
|
||||
dependencies = [
|
||||
"askama_derive",
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_derive"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
|
||||
dependencies = [
|
||||
"askama_parser",
|
||||
"basic-toml",
|
||||
"memchr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_parser"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
@@ -295,6 +337,15 @@ version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||
|
||||
[[package]]
|
||||
name = "basic-toml"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.2"
|
||||
@@ -3840,6 +3891,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"askama",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"axum-messages",
|
||||
|
||||
@@ -14,6 +14,7 @@ name = "zero2prod"
|
||||
[dependencies]
|
||||
anyhow = "1.0.99"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
askama = "0.14.0"
|
||||
axum = { version = "0.8.4", features = ["macros"] }
|
||||
axum-extra = { version = "0.10.1", features = ["query", "cookie"] }
|
||||
axum-messages = "0.8.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
application:
|
||||
port: 8000
|
||||
port: 8001
|
||||
host: "127.0.0.1"
|
||||
base_url: "http://127.0.0.1:8000"
|
||||
database:
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use askama::Template;
|
||||
use axum::response::Html;
|
||||
|
||||
pub async fn home() -> impl IntoResponse {
|
||||
Html(include_str!("home/home.html"))
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/home.html")]
|
||||
struct HomeTemplate;
|
||||
|
||||
pub async fn home() -> Html<String> {
|
||||
let template = HomeTemplate;
|
||||
Html(template.render().unwrap())
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Home</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Welcome to our newsletter!</p>
|
||||
<ol>
|
||||
<li><a href="/login">Admin login</a></li>
|
||||
<li><a href="/register">Register</a></li>
|
||||
</ol>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
session_state::TypedSession,
|
||||
startup::AppState,
|
||||
};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Form, Json,
|
||||
extract::State,
|
||||
@@ -50,18 +51,25 @@ impl IntoResponse for LoginError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/login.html")]
|
||||
struct LoginTemplate {
|
||||
error_html: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LoginFormData {
|
||||
username: String,
|
||||
password: SecretString,
|
||||
}
|
||||
|
||||
pub async fn get_login(messages: Messages) -> impl IntoResponse {
|
||||
pub async fn get_login(messages: Messages) -> Html<String> {
|
||||
let mut error_html = String::new();
|
||||
for message in messages {
|
||||
writeln!(error_html, "<p><i>{}</i></p>", message).unwrap();
|
||||
}
|
||||
Html(format!(include_str!("login/login.html"), error_html))
|
||||
let template = LoginTemplate { error_html };
|
||||
Html(template.render().unwrap())
|
||||
}
|
||||
|
||||
pub async fn post_login(
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body>
|
||||
<form action="/login" method="post">
|
||||
<input type="text" name="username" placeholder="Username" />
|
||||
<input type="password" name="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
{}
|
||||
</body>
|
||||
</html>
|
||||
81
templates/base.html
Normal file
81
templates/base.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}zero2prod{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<a
|
||||
href="/admin/dashboard"
|
||||
class="bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1">
|
||||
<main class="flex-1 lg:ml-0">
|
||||
<div class="py-8 px-4 sm:px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||
<div
|
||||
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<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
|
||||
<svg
|
||||
class="ml-1 h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="text-xs text-gray-500">
|
||||
Built with ❤️ using Rust, axum & htmx
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
180
templates/home.html
Normal file
180
templates/home.html
Normal file
@@ -0,0 +1,180 @@
|
||||
{% extends "base.html" %} {% block title %}Home - zero2prod{% endblock %} {%
|
||||
block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div
|
||||
class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 mb-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 my newsletter! Stay updated on my latest projects and
|
||||
thoughts. Unsubscribe at any time.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<a
|
||||
href="#newsletter-signup"
|
||||
class="bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors text-center"
|
||||
>
|
||||
Subscribe
|
||||
</a>
|
||||
<a
|
||||
href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
|
||||
target="_blank"
|
||||
class="border border-white text-white hover:bg-white hover:text-blue-600 font-semibold py-3 px-6 rounded-md transition-colors text-center"
|
||||
>
|
||||
View code
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature Cards -->
|
||||
<div class="grid md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div
|
||||
class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Idempotent</h3>
|
||||
<p class="text-gray-600 text-sm">
|
||||
Smart duplicate prevention ensures you'll never receive the same email
|
||||
twice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div
|
||||
class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Privacy first</h3>
|
||||
<p class="text-gray-600 text-sm">
|
||||
Zero spam, zero tracking, zero data sharing. Your email stays private
|
||||
and secure. Unsubscribe at any time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div
|
||||
class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Quality content</h3>
|
||||
<p class="text-gray-600 text-sm">
|
||||
Curated insights on Rust backend development, performance tips, and
|
||||
production war stories.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="newsletter-signup"
|
||||
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 my newsletter to get the latest updates.
|
||||
</p>
|
||||
|
||||
<form
|
||||
hx-post="/api/newsletter/subscribe"
|
||||
hx-target="#subscription-result"
|
||||
hx-swap="innerHTML"
|
||||
class="max-w-md mx-auto"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
class="flex-1 px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-6 rounded-md transition-colors"
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="subscription-result" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-gray-50 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4 text-center">Stats</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-blue-600" id="subscriber-count">
|
||||
2
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">subscribers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-orange-600">23</div>
|
||||
<div class="text-sm text-gray-600">emails sent</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-orange-600">0</div>
|
||||
<div class="text-sm text-gray-600">email opened</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-purple-600">~1ms</div>
|
||||
<div class="text-sm text-gray-600">response time</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
htmx.ajax("GET", "/api/stats/subscribers", {
|
||||
target: "#subscriber-count",
|
||||
});
|
||||
});
|
||||
</script> -->
|
||||
{% endblock %}
|
||||
</div>
|
||||
69
templates/login.html
Normal file
69
templates/login.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %} {% block title %}Login - zero2prod{% endblock %} {%
|
||||
block content %}
|
||||
<div class="min-h-[60vh] flex items-center justify-center">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-gray-900">Login</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Sign in to access the admin dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
|
||||
<form action="/login" method="post" class="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="username"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4">{{ error_html }}</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="/"
|
||||
class="text-sm text-blue-600 hover:text-blue-500 transition-colors"
|
||||
>
|
||||
← Back to homepage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user