Templates and TLS requests
Refactored HTML templates and added TLS back to issue HTTP requests
This commit is contained in:
115
Cargo.lock
generated
115
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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 %}><</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 %}>></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 %}
|
|
||||||
59
templates/dashboard/change_password.html
Normal file
59
templates/dashboard/change_password.html
Normal 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>
|
||||||
27
templates/dashboard/dashboard.html
Normal file
27
templates/dashboard/dashboard.html
Normal 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 %}
|
||||||
50
templates/dashboard/publish.html
Normal file
50
templates/dashboard/publish.html
Normal 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>
|
||||||
60
templates/dashboard/send_email.html
Normal file
60
templates/dashboard/send_email.html
Normal 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>
|
||||||
73
templates/dashboard/stats.html
Normal file
73
templates/dashboard/stats.html
Normal 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>
|
||||||
58
templates/dashboard/subscribers/list.html
Normal file
58
templates/dashboard/subscribers/list.html
Normal 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 %}><</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 %}>></button>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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
14
templates/message.html
Normal 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>
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user