Templates and TLS requests
All checks were successful
Rust / Test (push) Successful in 5m34s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Successful in 1m13s
Rust / Code coverage (push) Successful in 3m33s

Refactored HTML templates and added TLS back to issue HTTP requests
This commit is contained in:
Alphonse Paix
2025-09-29 02:39:53 +02:00
parent 3b727269c5
commit de44564ba0
29 changed files with 513 additions and 401 deletions

115
Cargo.lock generated
View File

@@ -339,6 +339,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.41" version = "0.4.41"
@@ -922,8 +928,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.1+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -933,9 +941,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasi 0.14.2+wasi-0.2.4", "wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1109,6 +1119,23 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.2",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.16" version = "0.1.16"
@@ -1414,6 +1441,12 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "markdown" name = "markdown"
version = "1.0.0" version = "1.0.0"
@@ -1862,6 +1895,61 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"
@@ -2018,16 +2106,21 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
@@ -2035,6 +2128,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots 1.0.2",
] ]
[[package]] [[package]]
@@ -2148,6 +2242,7 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -2774,6 +2869,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.17" version = "0.1.17"
@@ -3303,6 +3408,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"

View File

@@ -34,8 +34,9 @@ config = "0.15.14"
markdown = "1.0.0" markdown = "1.0.0"
rand = { version = "0.9.2", features = ["std_rng"] } rand = { version = "0.9.2", features = ["std_rng"] }
reqwest = { version = "0.12.23", default-features = false, features = [ reqwest = { version = "0.12.23", default-features = false, features = [
"json",
"cookies", "cookies",
"json",
"rustls-tls",
] } ] }
secrecy = { version = "0.10.3", features = ["serde"] } secrecy = { version = "0.10.3", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }

View File

@@ -27,7 +27,7 @@ pub use unsubscribe::*;
use crate::{ use crate::{
authentication::AuthError, authentication::AuthError,
templates::{HtmlTemplate, InternalErrorTemplate, MessageTemplate, NotFoundTemplate}, templates::{ErrorTemplate, HtmlTemplate, MessageTemplate},
}; };
pub fn generate_token() -> String { pub fn generate_token() -> String {
@@ -108,19 +108,16 @@ impl IntoResponse for AppError {
full_page, full_page,
} => { } => {
let html = if *full_page { let html = if *full_page {
Html(InternalErrorTemplate.render().unwrap()) Html(ErrorTemplate::InternalServer.render().unwrap())
} else { } else {
let template = MessageTemplate::Error { let template =
message: "An internal server error occured.".into(), MessageTemplate::error("An internal server error occured.".into());
};
Html(template.render().unwrap()) Html(template.render().unwrap())
}; };
html.into_response() html.into_response()
} }
AppError::FormError(error) => { AppError::FormError(error) => {
let template = MessageTemplate::Error { let template = MessageTemplate::error(error.to_string());
message: error.to_string(),
};
Html(template.render().unwrap()).into_response() Html(template.render().unwrap()).into_response()
} }
AppError::NotAuthenticated => { AppError::NotAuthenticated => {
@@ -164,7 +161,7 @@ pub async fn not_found() -> Response {
} }
pub fn not_found_html() -> Response { pub fn not_found_html() -> Response {
let template = HtmlTemplate(NotFoundTemplate); let template = HtmlTemplate(ErrorTemplate::NotFound);
(StatusCode::NOT_FOUND, template).into_response() (StatusCode::NOT_FOUND, template).into_response()
} }

View File

@@ -49,9 +49,7 @@ pub async fn change_password(
authentication::change_password(user_id, form.new_password, &connection_pool) authentication::change_password(user_id, form.new_password, &connection_pool)
.await .await
.map_err(AdminError::ChangePassword)?; .map_err(AdminError::ChangePassword)?;
let template = MessageTemplate::Success { let template = MessageTemplate::success("Your password has been changed.".to_string());
message: "Your password has been changed.".to_string(),
};
Ok(Html(template.render().unwrap()).into_response()) Ok(Html(template.render().unwrap()).into_response())
} }
} }

View File

@@ -127,7 +127,7 @@ pub async fn publish_newsletter(
.context("Failed to enqueue delivery tasks.")?; .context("Failed to enqueue delivery tasks.")?;
let message = String::from("Your email has been queued for delivery."); let message = String::from("Your email has been queued for delivery.");
let template = MessageTemplate::Success { message }; let template = MessageTemplate::success(message);
let response = Html(template.render().unwrap()).into_response(); let response = Html(template.render().unwrap()).into_response();
let response = save_response(transaction, &idempotency_key, user_id, response) let response = save_response(transaction, &idempotency_key, user_id, response)
.await .await

View File

@@ -73,9 +73,7 @@ pub async fn create_post(
.await .await
.context("Failed to enqueue delivery tasks.")?; .context("Failed to enqueue delivery tasks.")?;
let template = MessageTemplate::Success { let template = MessageTemplate::success("Your new post has been published!".into());
message: "Your new post has been published!".into(),
};
let response = Html(template.render().unwrap()).into_response(); let response = Html(template.render().unwrap()).into_response();
let response = save_response(transaction, &idempotency_key, user_id, response) let response = save_response(transaction, &idempotency_key, user_id, response)
.await .await
@@ -138,9 +136,7 @@ pub async fn delete_post(
"We could not find the post in the database." "We could not find the post in the database."
))) )))
} else { } else {
let template = MessageTemplate::Success { let template = MessageTemplate::success("The subscriber has been deleted.".into());
message: "The subscriber has been deleted.".into(),
};
Ok(template.render().unwrap().into_response()) Ok(template.render().unwrap().into_response())
} }
} }

View File

@@ -63,12 +63,10 @@ pub async fn delete_subscriber(
.map_err(AppError::unexpected_message)?; .map_err(AppError::unexpected_message)?;
if let Some(record) = res { if let Some(record) = res {
tracing::Span::current().record("email", tracing::field::display(&record.email)); tracing::Span::current().record("email", tracing::field::display(&record.email));
let template = MessageTemplate::Success { let template = MessageTemplate::success(format!(
message: format!(
"The subscriber with email '{}' has been deleted.", "The subscriber with email '{}' has been deleted.",
record.email record.email
), ));
};
Ok(template.render().unwrap().into_response()) Ok(template.render().unwrap().into_response())
} else { } else {
Err(AppError::unexpected_message(anyhow::anyhow!( Err(AppError::unexpected_message(anyhow::anyhow!(

View File

@@ -66,9 +66,8 @@ pub async fn subscribe(
.context("Failed to commit the database transaction to store a new subscriber.")?; .context("Failed to commit the database transaction to store a new subscriber.")?;
} }
let template = MessageTemplate::Success { let template =
message: "You'll receive a confirmation email shortly.".to_string(), MessageTemplate::success("You'll receive a confirmation email shortly.".to_string());
};
Ok(Html(template.render().unwrap()).into_response()) Ok(Html(template.render().unwrap()).into_response())
} }

View File

@@ -1,11 +1,9 @@
use crate::{ use crate::{
domain::SubscriberEmail, domain::SubscriberEmail,
email_client::EmailClient, email_client::EmailClient,
routes::AppError, routes::{AppError, not_found_html},
startup::AppState, startup::AppState,
templates::{ templates::{MessageTemplate, UnsubscribeConfirmTemplate, UnsubscribeTemplate},
MessageTemplate, NotFoundTemplate, UnsubscribeConfirmTemplate, UnsubscribeTemplate,
},
}; };
use anyhow::Context; use anyhow::Context;
use askama::Template; use askama::Template;
@@ -14,7 +12,6 @@ use axum::{
extract::{Query, State}, extract::{Query, State},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use reqwest::StatusCode;
use sqlx::{Executor, PgPool}; use sqlx::{Executor, PgPool};
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -52,9 +49,9 @@ pub async fn post_unsubscribe(
.await .await
.context("Failed to send a confirmation email.")?; .context("Failed to send a confirmation email.")?;
} }
let template = MessageTemplate::Success { let template = MessageTemplate::success(
message: "If you are a subscriber, you'll receive a confirmation link shortly.".into(), "If you are a subscriber, you'll receive a confirmation link shortly.".into(),
}; );
Ok(Html(template.render().unwrap()).into_response()) Ok(Html(template.render().unwrap()).into_response())
} }
@@ -124,11 +121,7 @@ pub async fn unsubscribe_confirm(
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
tracing::info!("Unsubscribe token is not tied to any confirmed user"); tracing::info!("Unsubscribe token is not tied to any confirmed user");
Ok(( Ok(not_found_html())
StatusCode::NOT_FOUND,
Html(NotFoundTemplate.render().unwrap()),
)
.into_response())
} else { } else {
tracing::info!("User successfully removed"); tracing::info!("User successfully removed");
Ok(Html(UnsubscribeConfirmTemplate.render().unwrap()).into_response()) Ok(Html(UnsubscribeConfirmTemplate.render().unwrap()).into_response())

View File

@@ -21,23 +21,33 @@ where
} }
#[derive(Template)] #[derive(Template)]
pub enum MessageTemplate { #[template(path = "message.html")]
#[template(path = "../templates/success.html")] pub struct MessageTemplate {
Success { message: String }, pub message: String,
#[template(path = "../templates/error.html")] pub error: bool,
Error { message: String },
} }
#[derive(Template)] impl MessageTemplate {
#[template(path = "../templates/500.html")] pub fn success(message: String) -> Self {
pub struct InternalErrorTemplate; Self {
message,
error: false,
}
}
pub fn error(message: String) -> Self {
Self {
message,
error: true,
}
}
}
#[derive(Template)] #[derive(Template)]
#[template(path = "../templates/login.html")] #[template(path = "../templates/login.html")]
pub struct LoginTemplate; pub struct LoginTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "../templates/dashboard.html")] #[template(path = "dashboard/dashboard.html")]
pub struct DashboardTemplate { pub struct DashboardTemplate {
pub username: String, pub username: String,
pub idempotency_key_1: String, pub idempotency_key_1: String,
@@ -53,27 +63,27 @@ pub struct DashboardTemplate {
pub struct HomeTemplate; pub struct HomeTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "posts.html")] #[template(path = "posts/list.html")]
pub struct PostsTemplate { pub struct PostsTemplate {
pub posts: Vec<PostEntry>, pub posts: Vec<PostEntry>,
pub next_page: Option<i64>, pub next_page: Option<i64>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "posts.html", block = "posts")] #[template(path = "posts/list.html", block = "posts")]
pub struct PostListTemplate { pub struct PostListTemplate {
pub posts: Vec<PostEntry>, pub posts: Vec<PostEntry>,
pub next_page: Option<i64>, pub next_page: Option<i64>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "post.html")] #[template(path = "posts/page.html")]
pub struct PostTemplate { pub struct PostTemplate {
pub post: PostEntry, pub post: PostEntry,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "dashboard.html", block = "subs")] #[template(path = "dashboard/subscribers/list.html", block = "subs")]
pub struct SubListTemplate { pub struct SubListTemplate {
pub subscribers: Vec<SubscriberEntry>, pub subscribers: Vec<SubscriberEntry>,
pub current_page: i64, pub current_page: i64,
@@ -81,19 +91,23 @@ pub struct SubListTemplate {
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "confirm.html")] #[template(path = "subscribe/confirm.html")]
pub struct ConfirmTemplate; pub struct ConfirmTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "404.html")] pub enum ErrorTemplate {
pub struct NotFoundTemplate; #[template(path = "error/404.html")]
NotFound,
#[template(path = "error/500.html")]
InternalServer,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "unsubscribe_confirm.html")] #[template(path = "unsubscribe/confirm.html")]
pub struct UnsubscribeConfirmTemplate; pub struct UnsubscribeConfirmTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "unsubscribe.html")] #[template(path = "unsubscribe/form.html")]
pub struct UnsubscribeTemplate; pub struct UnsubscribeTemplate;
#[derive(Template)] #[derive(Template)]

View File

@@ -1,322 +0,0 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="min-w-6/12 mx-auto p-4 sm:p-6">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-2 text-gray-600">
Connected as <span class="font-bold">{{ username }}</span>
</p>
<button hx-get="/admin/logout"
type="submit"
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Logout</span>
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-blue-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Subscribers</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.subscribers }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-purple-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Posts</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.posts }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center shrink-0">
<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="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Notifications</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.notifications_sent }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-orange-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Open rate</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.formatted_rate() }}</p>
</div>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Subscribers management
</h2>
<p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p>
</div>
</div>
</div>
<div id="subscribers-list" class="p-6 space-y-4">
{% block subs %}
{% if subscribers.is_empty() %}
<div class="bg-gray-50 rounded-lg p-8 border-2 border-dashed border-gray-300 text-center">
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No data available</h3>
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
</div>
{% else %}
{% for subscriber in subscribers %}
{% include "sub_card_fragment.html" %}
{% endfor %}
{% endif %}
<div class="flex items-center justify-center space-x-2">
<button hx-get="/admin/subscribers?page={{ current_page - 1 }}"
hx-target="#subscribers-list"
hx-swap="innerHTML"
hx-trigger="click"
class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 {% if current_page <= 1 %}opacity-50 cursor-not-allowed{% endif %}"
{% if current_page <= 1 %}disabled{% endif %}>&lt;</button>
<span class="px-3 py-1 text-sm font-medium text-gray-700 bg-gray-100 rounded-md">Page: {{ current_page }}</span>
<button hx-get="/admin/subscribers?page={{ current_page + 1 }}"
hx-target="#subscribers-list"
hx-swap="innerHTML"
hx-trigger="click"
class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 {% if current_page >= max_page %}opacity-50 cursor-not-allowed{% endif %}"
{% if current_page >= max_page %}disabled{% endif %}>&gt;</button>
</div>
{% endblock %}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-purple-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
Write a new post
</h2>
<p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p>
</div>
<div class="p-6">
<form hx-post="/admin/posts"
hx-target="#post-messages"
hx-swap="innerHTML"
class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}" />
<div>
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
<input type="text"
id="post-title"
name="title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
</div>
<div>
<label for="post-content"
class="block text-sm font-medium text-gray-700 mb-2">Markdown content</label>
<textarea id="post-content"
name="content"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"></textarea>
</div>
<button type="submit"
class="w-full bg-purple-600 text-white hover:bg-purple-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
Publish
</button>
<div id="post-messages" class="mt-4"></div>
</form>
</div>
</div>
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Send an email
</h2>
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
</div>
<div class="p-6">
<form hx-post="/admin/newsletters"
hx-target="#newsletter-messages"
hx-swap="innerHTML"
class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}" />
<div>
<label for="newsletter-title"
class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
<input type="text"
id="newsletter-title"
name="title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label for="newsletter-html"
class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
<textarea id="newsletter-html"
name="html"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
</div>
<div>
<label for="newsletter-text"
class="block text-sm font-medium text-gray-700 mb-2">Text content</label>
<textarea id="newsletter-text"
name="text"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send
</button>
<div id="newsletter-messages" class="mt-4"></div>
</form>
</div>
</div>
<div class="bg-white rounded-lg shadow-md border border-gray-200 lg:col-span-2">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-green-600 mr-2"
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>
Change your password
</h2>
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
</div>
<div class="p-6">
<form hx-post="/admin/password"
hx-target="#password-messages"
hx-swap="innerHTML"
class="space-y-4">
<div>
<label for="current_password"
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
<input type="password"
id="current_password"
name="current_password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<div>
<label for="new_password"
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
<input type="password"
id="new_password"
name="new_password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<div>
<label for="new_password_check"
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
<input type="password"
id="new_password_check"
name="new_password_check"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<button type="submit"
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update password
</button>
<div id="password-messages" class="mt-4"></div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,59 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200 lg:col-span-2">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-green-600 mr-2"
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>
Change your password
</h2>
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
</div>
<div class="p-6">
<form hx-post="/admin/password"
hx-target="#password-messages"
hx-swap="innerHTML"
class="space-y-4">
<div>
<label for="current_password"
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
<input type="password"
id="current_password"
name="current_password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<div>
<label for="new_password"
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
<input type="password"
id="new_password"
name="new_password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<div>
<label for="new_password_check"
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
<input type="password"
id="new_password_check"
name="new_password_check"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<button type="submit"
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update password
</button>
<div id="password-messages" class="mt-4"></div>
</form>
</div>
</div>

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="min-w-6/12 mx-auto p-4 sm:p-6">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-2 text-gray-600">
Connected as <span class="font-bold">{{ username }}</span>
</p>
<button hx-get="/admin/logout"
type="submit"
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Logout</span>
</button>
</div>
{% include "stats.html" %}
{% include "subscribers/list.html" %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{% include "publish.html" %}
{% include "send_email.html" %}
{% include "change_password.html" %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-purple-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
Write a new post
</h2>
<p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p>
</div>
<div class="p-6">
<form hx-post="/admin/posts"
hx-target="#post-messages"
hx-swap="innerHTML"
class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}" />
<div>
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
<input type="text"
id="post-title"
name="title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
</div>
<div>
<label for="post-content"
class="block text-sm font-medium text-gray-700 mb-2">Markdown content</label>
<textarea id="post-content"
name="content"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"></textarea>
</div>
<button type="submit"
class="w-full bg-purple-600 text-white hover:bg-purple-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
Publish
</button>
<div id="post-messages" class="mt-4"></div>
</form>
</div>
</div>

View File

@@ -0,0 +1,60 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
Send an email
</h2>
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
</div>
<div class="p-6">
<form hx-post="/admin/newsletters"
hx-target="#newsletter-messages"
hx-swap="innerHTML"
class="space-y-4">
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}" />
<div>
<label for="newsletter-title"
class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
<input type="text"
id="newsletter-title"
name="title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label for="newsletter-html"
class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
<textarea id="newsletter-html"
name="html"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
</div>
<div>
<label for="newsletter-text"
class="block text-sm font-medium text-gray-700 mb-2">Text content</label>
<textarea id="newsletter-text"
name="text"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send
</button>
<div id="newsletter-messages" class="mt-4"></div>
</form>
</div>
</div>

View File

@@ -0,0 +1,73 @@
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-blue-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Subscribers</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.subscribers }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-purple-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Posts</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.posts }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center shrink-0">
<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="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Notifications</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.notifications_sent }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-orange-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Open rate</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.formatted_rate() }}</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,58 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Subscribers management
</h2>
<p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p>
</div>
</div>
</div>
<div id="subscribers-list" class="p-6 space-y-4">
{% block subs %}
{% if subscribers.is_empty() %}
<div class="bg-gray-50 rounded-lg p-8 border-2 border-dashed border-gray-300 text-center">
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No data available</h3>
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
</div>
{% else %}
{% for subscriber in subscribers %}
{% include "dashboard/subscribers/card.html" %}
{% endfor %}
{% endif %}
<div class="flex items-center justify-center space-x-2">
<button hx-get="/admin/subscribers?page={{ current_page - 1 }}"
hx-target="#subscribers-list"
hx-swap="innerHTML"
hx-trigger="click"
class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 {% if current_page <= 1 %}opacity-50 cursor-not-allowed{% endif %}"
{% if current_page <= 1 %}disabled{% endif %}>&lt;</button>
<span class="px-3 py-1 text-sm font-medium text-gray-700 bg-gray-100 rounded-md">Page: {{ current_page }}</span>
<button hx-get="/admin/subscribers?page={{ current_page + 1 }}"
hx-target="#subscribers-list"
hx-swap="innerHTML"
hx-trigger="click"
class="px-3 py-1 text-sm font-medium text-gray-500 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 {% if current_page >= max_page %}opacity-50 cursor-not-allowed{% endif %}"
{% if current_page >= max_page %}disabled{% endif %}>&gt;</button>
</div>
{% endblock %}
</div>
</div>

View File

@@ -1,9 +0,0 @@
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd">
</path>
</svg>
<span class="font-medium">{{ message }}</span>
</div>
</div>

14
templates/message.html Normal file
View File

@@ -0,0 +1,14 @@
<div class="{% if self.error %}bg-red-50 border border-red-200 text-red-700{% else %}bg-green-50 border border-green-200 text-green-700{% endif %} px-4 py-3 rounded-md">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
{% if self.error %}
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd">
</path>
{% else %}
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd">
</path>
{% endif %}
</svg>
<span class="font-medium">{{ message }}</span>
</div>
</div>

View File

@@ -24,7 +24,7 @@
<div class="space-y-6"> <div class="space-y-6">
{% block posts %} {% block posts %}
{% for post in posts %} {% for post in posts %}
{% include "post_card_fragment.html" %} {% include "card.html" %}
{% endfor %} {% endfor %}
<div id="load-more" class="text-center"> <div id="load-more" class="text-center">
{% if let Some(n) = next_page %} {% if let Some(n) = next_page %}

View File

@@ -1,9 +0,0 @@
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd">
</path>
</svg>
<span class="font-medium">{{ message }}</span>
</div>
</div>