UI
This commit is contained in:
344
src/controllers/frontend.rs
Normal file
344
src/controllers/frontend.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
use crate::{
|
||||
controllers::{admin, auth as auth_controller},
|
||||
models::{
|
||||
_entities::{blog_articles, site_pages},
|
||||
users::{self, LoginParams},
|
||||
},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
const ABOUT_SLUG: &str = "about";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArticleForm {
|
||||
title: String,
|
||||
content: String,
|
||||
excerpt: Option<String>,
|
||||
published: Option<String>,
|
||||
featured_image_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AboutForm {
|
||||
title: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
fn slugify(title: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
|
||||
for ch in title.chars().flat_map(char::to_lowercase) {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
Uuid::new_v4().to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
||||
published.then(|| Utc::now().into())
|
||||
}
|
||||
|
||||
fn is_checked(value: &Option<String>) -> bool {
|
||||
value.as_deref().is_some_and(|value| value == "on" || value == "true")
|
||||
}
|
||||
|
||||
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn about_page(ctx: &AppContext) -> Result<site_pages::Model> {
|
||||
site_pages::Entity::find()
|
||||
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
|
||||
blog_articles::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn home(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let articles = blog_articles::Entity::find()
|
||||
.filter(blog_articles::Column::Published.eq(true))
|
||||
.order_by_desc(blog_articles::Column::PublishedAt)
|
||||
.limit(5)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(&v, "home/index.html", json!({ "articles": articles }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn about(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
format::view(&v, "pages/about.html", json!({ "page": about_page(&ctx).await? }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn blog_index(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let articles = blog_articles::Entity::find()
|
||||
.filter(blog_articles::Column::Published.eq(true))
|
||||
.order_by_desc(blog_articles::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(&v, "blog/index.html", json!({ "articles": articles }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn blog_show(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let article = blog_articles::Entity::find()
|
||||
.filter(blog_articles::Column::Slug.eq(slug))
|
||||
.filter(blog_articles::Column::Published.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let mut active = article.into_active_model();
|
||||
let next_count = active.view_count.as_ref().to_owned() + 1;
|
||||
active.view_count = Set(next_count);
|
||||
let article = active.update(&ctx.db).await?;
|
||||
|
||||
format::view(&v, "blog/show.html", json!({ "article": article }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_login_page(ViewEngine(v): ViewEngine<TeraView>) -> Result<Response> {
|
||||
format::view(&v, "admin/login.html", json!({ "error": null }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_login(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<LoginParams>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" }));
|
||||
};
|
||||
|
||||
if !user.verify_password(¶ms.password) || !admin::is_admin(&ctx, &user) {
|
||||
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" }));
|
||||
}
|
||||
|
||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||
let token = user
|
||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::render()
|
||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
||||
.redirect("/admin/dashboard")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_logout() -> Result<Response> {
|
||||
format::render()
|
||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
||||
.redirect("/admin/login")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_home(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
||||
format::view(&v, "admin/index.html", json!({ "admin": admin_user }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_about(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(&v, "admin/about.html", json!({ "page": about_page(&ctx).await? }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_about_update(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<AboutForm>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let mut page = about_page(&ctx).await?.into_active_model();
|
||||
page.title = Set(params.title);
|
||||
page.content = Set(params.content);
|
||||
page.update(&ctx.db).await?;
|
||||
format::redirect("/admin/about")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_articles(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let articles = blog_articles::Entity::find()
|
||||
.order_by_desc(blog_articles::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
format::view(&v, "admin/blog/index.html", json!({ "articles": articles }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_new(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(&v, "admin/blog/new.html", json!({}))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<ArticleForm>,
|
||||
) -> Result<Response> {
|
||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
||||
let published = is_checked(¶ms.published);
|
||||
|
||||
blog_articles::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
title: Set(params.title.clone()),
|
||||
slug: Set(slugify(¶ms.title)),
|
||||
content: Set(params.content),
|
||||
excerpt: Set(normalize_empty(params.excerpt)),
|
||||
published: Set(published),
|
||||
author_id: Set(admin_user.id),
|
||||
featured_image_id: Set(normalize_empty(params.featured_image_id)),
|
||||
view_count: Set(0),
|
||||
published_at: Set(published_at_for(published)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::redirect("/admin/blog/articles")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_edit(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<Uuid>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/blog/edit.html",
|
||||
json!({ "article": article_by_id(&ctx, id).await? }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<Uuid>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<ArticleForm>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let existing = article_by_id(&ctx, id).await?;
|
||||
let was_published = existing.published;
|
||||
let published = is_checked(¶ms.published);
|
||||
|
||||
let mut article = existing.into_active_model();
|
||||
article.title = Set(params.title.clone());
|
||||
article.slug = Set(slugify(¶ms.title));
|
||||
article.content = Set(params.content);
|
||||
article.excerpt = Set(normalize_empty(params.excerpt));
|
||||
article.published = Set(published);
|
||||
article.featured_image_id = Set(normalize_empty(params.featured_image_id));
|
||||
if published && !was_published {
|
||||
article.published_at = Set(published_at_for(true));
|
||||
} else if !published {
|
||||
article.published_at = Set(None);
|
||||
}
|
||||
article.update(&ctx.db).await?;
|
||||
|
||||
format::redirect("/admin/blog/articles")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<Uuid>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
article_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/blog/articles")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/", get(home))
|
||||
.add("/about", get(about))
|
||||
.add("/blog", get(blog_index))
|
||||
.add("/blog/{slug}", get(blog_show))
|
||||
.add("/admin/login", get(admin_login_page))
|
||||
.add("/admin/login", post(admin_login))
|
||||
.add("/admin/logout", post(admin_logout))
|
||||
.add("/admin", get(admin_login_page))
|
||||
.add("/admin/dashboard", get(admin_home))
|
||||
.add("/admin/about", get(admin_about))
|
||||
.add("/admin/about", post(admin_about_update))
|
||||
.add("/admin/blog/articles", get(admin_articles))
|
||||
.add("/admin/blog/articles/new", get(admin_article_new))
|
||||
.add("/admin/blog/articles", post(admin_article_create))
|
||||
.add("/admin/blog/articles/{id}/edit", get(admin_article_edit))
|
||||
.add("/admin/blog/articles/{id}", post(admin_article_update))
|
||||
.add("/admin/blog/articles/{id}/delete", post(admin_article_delete))
|
||||
}
|
||||
Reference in New Issue
Block a user