use crate::{ controllers::admin, models::_entities::blog_articles, }; use chrono::Utc; use loco_rs::prelude::*; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Deserialize)] struct ArticleParams { title: String, content: String, excerpt: Option, published: Option, featured_image_id: Option, } #[derive(Debug, Serialize)] struct ArticleResponse { id: Uuid, title: String, slug: String, content: String, excerpt: Option, published: bool, author_id: i32, featured_image_id: Option, view_count: i32, created_at: chrono::DateTime, updated_at: chrono::DateTime, published_at: Option>, } #[derive(Debug, Serialize)] struct ArticleListResponse { articles: Vec, } impl From for ArticleResponse { fn from(article: blog_articles::Model) -> Self { Self { id: article.id, title: article.title, slug: article.slug, content: article.content, excerpt: article.excerpt, published: article.published, author_id: article.author_id, featured_image_id: article.featured_image_id, view_count: article.view_count, created_at: article.created_at, updated_at: article.updated_at, published_at: article.published_at, } } } 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> { published.then(|| Utc::now().into()) } async fn find_article_by_id(ctx: &AppContext, id: Uuid) -> Result { blog_articles::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound) } #[debug_handler] async fn public_index(State(ctx): State) -> Result { let articles = blog_articles::Entity::find() .filter(blog_articles::Column::Published.eq(true)) .order_by_desc(blog_articles::Column::PublishedAt) .all(&ctx.db) .await? .into_iter() .map(ArticleResponse::from) .collect(); format::json(ArticleListResponse { articles }) } #[debug_handler] async fn public_show(Path(slug): Path, State(ctx): State) -> Result { 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::json(ArticleResponse::from(article)) } #[debug_handler] async fn admin_index(auth: auth::JWT, State(ctx): State) -> Result { admin::current_admin(auth, &ctx).await?; let articles = blog_articles::Entity::find() .order_by_desc(blog_articles::Column::CreatedAt) .all(&ctx.db) .await? .into_iter() .map(ArticleResponse::from) .collect(); format::json(ArticleListResponse { articles }) } #[debug_handler] async fn admin_create( auth: auth::JWT, State(ctx): State, Json(params): Json, ) -> Result { let admin_user = admin::current_admin(auth, &ctx).await?; let published = params.published.unwrap_or(false); let article = blog_articles::ActiveModel { id: Set(Uuid::new_v4()), title: Set(params.title.clone()), slug: Set(slugify(¶ms.title)), content: Set(params.content), excerpt: Set(params.excerpt), published: Set(published), author_id: Set(admin_user.id), featured_image_id: Set(params.featured_image_id), view_count: Set(0), published_at: Set(published_at_for(published)), ..Default::default() } .insert(&ctx.db) .await?; format::json(ArticleResponse::from(article)) } #[debug_handler] async fn admin_update( auth: auth::JWT, Path(id): Path, State(ctx): State, Json(params): Json, ) -> Result { admin::current_admin(auth, &ctx).await?; let existing = find_article_by_id(&ctx, id).await?; let was_published = existing.published; let published = params.published.unwrap_or(was_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(params.excerpt); article.published = Set(published); article.featured_image_id = Set(params.featured_image_id); if published && !was_published { article.published_at = Set(published_at_for(true)); } else if !published { article.published_at = Set(None); } let article = article.update(&ctx.db).await?; format::json(ArticleResponse::from(article)) } #[debug_handler] async fn admin_delete(auth: auth::JWT, Path(id): Path, State(ctx): State) -> Result { admin::current_admin(auth, &ctx).await?; let article = find_article_by_id(&ctx, id).await?; article.delete(&ctx.db).await?; format::json(()) } #[debug_handler] async fn admin_publish(auth: auth::JWT, Path(id): Path, State(ctx): State) -> Result { admin::current_admin(auth, &ctx).await?; let mut article = find_article_by_id(&ctx, id).await?.into_active_model(); article.published = Set(true); article.published_at = Set(published_at_for(true)); let article = article.update(&ctx.db).await?; format::json(ArticleResponse::from(article)) } #[debug_handler] async fn admin_unpublish(auth: auth::JWT, Path(id): Path, State(ctx): State) -> Result { admin::current_admin(auth, &ctx).await?; let mut article = find_article_by_id(&ctx, id).await?.into_active_model(); article.published = Set(false); article.published_at = Set(None); let article = article.update(&ctx.db).await?; format::json(ArticleResponse::from(article)) } pub fn routes() -> Routes { Routes::new() .prefix("/api") .add("/blog", get(public_index)) .add("/blog/{slug}", get(public_show)) .add("/admin/blog/articles", get(admin_index)) .add("/admin/blog/articles", post(admin_create)) .add("/admin/blog/articles/{id}", put(admin_update)) .add("/admin/blog/articles/{id}", delete(admin_delete)) .add("/admin/blog/articles/{id}/publish", post(admin_publish)) .add("/admin/blog/articles/{id}/unpublish", post(admin_unpublish)) }