Dashboard subscribers widget
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,7 +1,9 @@
|
|||||||
mod new_subscriber;
|
mod new_subscriber;
|
||||||
mod post;
|
mod post;
|
||||||
mod subscriber_email;
|
mod subscriber_email;
|
||||||
|
mod subscribers;
|
||||||
|
|
||||||
pub use new_subscriber::NewSubscriber;
|
pub use new_subscriber::NewSubscriber;
|
||||||
pub use post::PostEntry;
|
pub use post::PostEntry;
|
||||||
pub use subscriber_email::SubscriberEmail;
|
pub use subscriber_email::SubscriberEmail;
|
||||||
|
pub use subscribers::SubscriberEntry;
|
||||||
|
|||||||
21
src/domain/subscribers.rs
Normal file
21
src/domain/subscribers.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct SubscriberEntry {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub subscribed_at: DateTime<Utc>,
|
||||||
|
pub status: String,
|
||||||
|
pub unsubscribe_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubscriberEntry {
|
||||||
|
pub fn confirmed(&self) -> bool {
|
||||||
|
self.status == "confirmed"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn formatted_date(&self) -> String {
|
||||||
|
self.subscribed_at.format("%B %d, %Y").to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ mod dashboard;
|
|||||||
mod logout;
|
mod logout;
|
||||||
mod newsletters;
|
mod newsletters;
|
||||||
mod posts;
|
mod posts;
|
||||||
|
mod subscribers;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthenticatedUser,
|
authentication::AuthenticatedUser,
|
||||||
@@ -15,6 +16,7 @@ pub use dashboard::*;
|
|||||||
pub use logout::*;
|
pub use logout::*;
|
||||||
pub use newsletters::*;
|
pub use newsletters::*;
|
||||||
pub use posts::*;
|
pub use posts::*;
|
||||||
|
pub use subscribers::*;
|
||||||
|
|
||||||
#[derive(thiserror::Error)]
|
#[derive(thiserror::Error)]
|
||||||
pub enum AdminError {
|
pub enum AdminError {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthenticatedUser, routes::AppError, startup::AppState,
|
authentication::AuthenticatedUser,
|
||||||
|
routes::{AppError, get_max_page, get_subs, get_total_subs},
|
||||||
|
startup::AppState,
|
||||||
templates::DashboardTemplate,
|
templates::DashboardTemplate,
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
@@ -9,6 +11,7 @@ use axum::{
|
|||||||
extract::State,
|
extract::State,
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct DashboardStats {
|
pub struct DashboardStats {
|
||||||
@@ -33,16 +36,28 @@ pub async fn admin_dashboard(
|
|||||||
let stats = get_stats(&connection_pool).await?;
|
let stats = get_stats(&connection_pool).await?;
|
||||||
let idempotency_key_1 = Uuid::new_v4().to_string();
|
let idempotency_key_1 = Uuid::new_v4().to_string();
|
||||||
let idempotency_key_2 = Uuid::new_v4().to_string();
|
let idempotency_key_2 = Uuid::new_v4().to_string();
|
||||||
|
let current_page = 1;
|
||||||
|
let subscribers = get_subs(&connection_pool, current_page)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch subscribers from database.")
|
||||||
|
.map_err(AppError::unexpected_message)?;
|
||||||
|
let count = get_total_subs(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch total subscribers count from the database.")?;
|
||||||
|
let max_page = get_max_page(count);
|
||||||
let template = DashboardTemplate {
|
let template = DashboardTemplate {
|
||||||
username,
|
username,
|
||||||
idempotency_key_1,
|
idempotency_key_1,
|
||||||
idempotency_key_2,
|
idempotency_key_2,
|
||||||
stats,
|
stats,
|
||||||
|
subscribers,
|
||||||
|
current_page,
|
||||||
|
max_page,
|
||||||
};
|
};
|
||||||
Ok(Html(template.render().unwrap()).into_response())
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_stats(connection_pool: &sqlx::PgPool) -> Result<DashboardStats, anyhow::Error> {
|
async fn get_stats(connection_pool: &PgPool) -> Result<DashboardStats, anyhow::Error> {
|
||||||
let subscribers =
|
let subscribers =
|
||||||
sqlx::query_scalar!("SELECT count(*) FROM subscriptions WHERE status = 'confirmed'")
|
sqlx::query_scalar!("SELECT count(*) FROM subscriptions WHERE status = 'confirmed'")
|
||||||
.fetch_one(connection_pool)
|
.fetch_one(connection_pool)
|
||||||
|
|||||||
99
src/routes/admin/subscribers.rs
Normal file
99
src/routes/admin/subscribers.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
use crate::{
|
||||||
|
domain::SubscriberEntry,
|
||||||
|
routes::AppError,
|
||||||
|
startup::AppState,
|
||||||
|
templates::{MessageTemplate, SubListTemplate},
|
||||||
|
};
|
||||||
|
use anyhow::Context;
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const SUBS_PER_PAGE: i64 = 5;
|
||||||
|
|
||||||
|
pub async fn get_subscribers_page(
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
Query(SubsQueryParams { page }): Query<SubsQueryParams>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let count = get_total_subs(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch total subscribers count from the database.")
|
||||||
|
.map_err(AppError::unexpected_message)?;
|
||||||
|
let max_page = get_max_page(count);
|
||||||
|
let subscribers = get_subs(&connection_pool, page)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch subscribers data.")
|
||||||
|
.map_err(AppError::unexpected_message)?;
|
||||||
|
let template = SubListTemplate {
|
||||||
|
subscribers,
|
||||||
|
current_page: page,
|
||||||
|
max_page,
|
||||||
|
};
|
||||||
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_subscriber(
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
Path(subscriber_id): Path<Uuid>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let res = sqlx::query!("DELETE FROM subscriptions WHERE id = $1", subscriber_id)
|
||||||
|
.execute(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to delete subscriber from database.")
|
||||||
|
.map_err(AppError::unexpected_message)?;
|
||||||
|
if res.rows_affected() > 1 {
|
||||||
|
Err(AppError::unexpected_message(anyhow::anyhow!(
|
||||||
|
"We could not find the subscriber in the database."
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
let template = MessageTemplate::Success {
|
||||||
|
message: "The subscriber has been deleted.".into(),
|
||||||
|
};
|
||||||
|
Ok(template.render().unwrap().into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_subs(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
page: i64,
|
||||||
|
) -> Result<Vec<SubscriberEntry>, sqlx::Error> {
|
||||||
|
let offset = (page - 1) * SUBS_PER_PAGE;
|
||||||
|
let subscribers = sqlx::query_as!(
|
||||||
|
SubscriberEntry,
|
||||||
|
"SELECT * FROM subscriptions ORDER BY subscribed_at DESC LIMIT $1 OFFSET $2",
|
||||||
|
SUBS_PER_PAGE,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(connection_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(subscribers)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_total_subs(connection_pool: &PgPool) -> Result<i64, sqlx::Error> {
|
||||||
|
let count = sqlx::query_scalar!("SELECT count(*) FROM subscriptions")
|
||||||
|
.fetch_one(connection_pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_max_page(count: i64) -> i64 {
|
||||||
|
let mut max_page = count.div_euclid(SUBS_PER_PAGE);
|
||||||
|
if count % SUBS_PER_PAGE > 0 {
|
||||||
|
max_page += 1;
|
||||||
|
}
|
||||||
|
max_page
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct SubsQueryParams {
|
||||||
|
page: i64,
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
http::Request,
|
http::Request,
|
||||||
middleware,
|
middleware,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
use axum_server::tls_rustls::RustlsConfig;
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use reqwest::{StatusCode, header};
|
use reqwest::{StatusCode, header};
|
||||||
@@ -125,6 +125,8 @@ pub fn app(
|
|||||||
.route("/newsletters", post(publish_newsletter))
|
.route("/newsletters", post(publish_newsletter))
|
||||||
.route("/posts", post(create_post))
|
.route("/posts", post(create_post))
|
||||||
.route("/logout", post(logout))
|
.route("/logout", post(logout))
|
||||||
|
.route("/subscribers", get(get_subscribers_page))
|
||||||
|
.route("/subscribers/{subscriber_id}", delete(delete_subscriber))
|
||||||
.layer(middleware::from_fn(require_auth));
|
.layer(middleware::from_fn(require_auth));
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(home))
|
.route("/", get(home))
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use crate::{domain::PostEntry, routes::DashboardStats};
|
use crate::{
|
||||||
|
domain::{PostEntry, SubscriberEntry},
|
||||||
|
routes::DashboardStats,
|
||||||
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -25,6 +28,9 @@ pub struct DashboardTemplate {
|
|||||||
pub idempotency_key_1: String,
|
pub idempotency_key_1: String,
|
||||||
pub idempotency_key_2: String,
|
pub idempotency_key_2: String,
|
||||||
pub stats: DashboardStats,
|
pub stats: DashboardStats,
|
||||||
|
pub subscribers: Vec<SubscriberEntry>,
|
||||||
|
pub current_page: i64,
|
||||||
|
pub max_page: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@@ -51,6 +57,14 @@ pub struct PostTemplate {
|
|||||||
pub post: PostEntry,
|
pub post: PostEntry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard.html", block = "subs")]
|
||||||
|
pub struct SubListTemplate {
|
||||||
|
pub subscribers: Vec<SubscriberEntry>,
|
||||||
|
pub current_page: i64,
|
||||||
|
pub max_page: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "confirm.html")]
|
#[template(path = "confirm.html")]
|
||||||
pub struct ConfirmTemplate;
|
pub struct ConfirmTemplate;
|
||||||
|
|||||||
@@ -89,6 +89,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 mb-4">Content may have shifted due to recent updates.</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="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
<p class="text-gray-600">No more posts. Check back later for more!</p>
|
<p class="text-gray-600">No more posts. Check back later for more!</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
24
templates/sub_card_fragment.html
Normal file
24
templates/sub_card_fragment.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<div id="subscriber-{{ subscriber.id }}"
|
||||||
|
class="bg-gray-50 rounded-lg p-4 border border-gray-200 {% if subscriber.confirmed() %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-yellow-500{% endif %}">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-md font-medium text-gray-900 break-all">{{ subscriber.email }}</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">{{ subscriber.formatted_date() }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 sm:mt-0 sm:ml-4">
|
||||||
|
<button hx-delete="/admin/subscribers/{{ subscriber.id }}"
|
||||||
|
hx-target="#subscriber-{{ subscriber.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Are you sure you want to delete this subscriber?"
|
||||||
|
class="inline-flex items-center py-1 px-2 text-sm font-medium text-red-500 bg-red-50 border-2 border-dashed border-red-300 rounded-md hover:bg-red-100 hover:border-red-400 hover:text-red-600 transition-all duration-200 group">
|
||||||
|
<svg class="w-4 h-4 mr-2 group-hover:scale-110 transition-transform"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user