feat: add shadcn admin workspace
This commit is contained in:
408
backend/src/controllers/admin_api.rs
Normal file
408
backend/src/controllers/admin_api.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, QuerySelect,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
controllers::{
|
||||
admin::{admin_username, check_auth, is_admin_logged_in, set_admin_logged_in, validate_admin_credentials},
|
||||
site_settings::{self, SiteSettingsPayload},
|
||||
},
|
||||
models::_entities::{ai_chunks, comments, friend_links, posts, reviews},
|
||||
services::{ai, content},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminLoginPayload {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminSessionResponse {
|
||||
pub authenticated: bool,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DashboardStats {
|
||||
pub total_posts: u64,
|
||||
pub total_comments: u64,
|
||||
pub pending_comments: u64,
|
||||
pub total_categories: u64,
|
||||
pub total_tags: u64,
|
||||
pub total_reviews: u64,
|
||||
pub total_links: u64,
|
||||
pub pending_links: u64,
|
||||
pub ai_chunks: u64,
|
||||
pub ai_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DashboardPostItem {
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub post_type: String,
|
||||
pub pinned: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DashboardCommentItem {
|
||||
pub id: i32,
|
||||
pub author: String,
|
||||
pub post_slug: String,
|
||||
pub scope: String,
|
||||
pub excerpt: String,
|
||||
pub approved: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DashboardFriendLinkItem {
|
||||
pub id: i32,
|
||||
pub site_name: String,
|
||||
pub site_url: String,
|
||||
pub category: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DashboardReviewItem {
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
pub review_type: String,
|
||||
pub rating: i32,
|
||||
pub status: String,
|
||||
pub review_date: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DashboardSiteSummary {
|
||||
pub site_name: String,
|
||||
pub site_url: String,
|
||||
pub ai_enabled: bool,
|
||||
pub ai_chunks: u64,
|
||||
pub ai_last_indexed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminDashboardResponse {
|
||||
pub stats: DashboardStats,
|
||||
pub site: DashboardSiteSummary,
|
||||
pub recent_posts: Vec<DashboardPostItem>,
|
||||
pub pending_comments: Vec<DashboardCommentItem>,
|
||||
pub pending_friend_links: Vec<DashboardFriendLinkItem>,
|
||||
pub recent_reviews: Vec<DashboardReviewItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminSiteSettingsResponse {
|
||||
pub id: i32,
|
||||
pub site_name: Option<String>,
|
||||
pub site_short_name: Option<String>,
|
||||
pub site_url: Option<String>,
|
||||
pub site_title: Option<String>,
|
||||
pub site_description: Option<String>,
|
||||
pub hero_title: Option<String>,
|
||||
pub hero_subtitle: Option<String>,
|
||||
pub owner_name: Option<String>,
|
||||
pub owner_title: Option<String>,
|
||||
pub owner_bio: Option<String>,
|
||||
pub owner_avatar_url: Option<String>,
|
||||
pub social_github: Option<String>,
|
||||
pub social_twitter: Option<String>,
|
||||
pub social_email: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub tech_stack: Vec<String>,
|
||||
pub ai_enabled: bool,
|
||||
pub ai_provider: Option<String>,
|
||||
pub ai_api_base: Option<String>,
|
||||
pub ai_api_key: Option<String>,
|
||||
pub ai_chat_model: Option<String>,
|
||||
pub ai_embedding_model: Option<String>,
|
||||
pub ai_system_prompt: Option<String>,
|
||||
pub ai_top_k: Option<i32>,
|
||||
pub ai_chunk_size: Option<i32>,
|
||||
pub ai_last_indexed_at: Option<String>,
|
||||
pub ai_chunks_count: u64,
|
||||
pub ai_local_embedding: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminAiReindexResponse {
|
||||
pub indexed_chunks: usize,
|
||||
pub last_indexed_at: Option<String>,
|
||||
}
|
||||
|
||||
fn format_timestamp(
|
||||
value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
|
||||
pattern: &str,
|
||||
) -> Option<String> {
|
||||
value.map(|item| item.format(pattern).to_string())
|
||||
}
|
||||
|
||||
fn required_text(value: Option<&str>, fallback: &str) -> String {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
|
||||
value
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|item| item.as_str().map(ToString::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_settings_response(
|
||||
item: crate::models::_entities::site_settings::Model,
|
||||
ai_chunks_count: u64,
|
||||
) -> AdminSiteSettingsResponse {
|
||||
AdminSiteSettingsResponse {
|
||||
id: item.id,
|
||||
site_name: item.site_name,
|
||||
site_short_name: item.site_short_name,
|
||||
site_url: item.site_url,
|
||||
site_title: item.site_title,
|
||||
site_description: item.site_description,
|
||||
hero_title: item.hero_title,
|
||||
hero_subtitle: item.hero_subtitle,
|
||||
owner_name: item.owner_name,
|
||||
owner_title: item.owner_title,
|
||||
owner_bio: item.owner_bio,
|
||||
owner_avatar_url: item.owner_avatar_url,
|
||||
social_github: item.social_github,
|
||||
social_twitter: item.social_twitter,
|
||||
social_email: item.social_email,
|
||||
location: item.location,
|
||||
tech_stack: tech_stack_values(&item.tech_stack),
|
||||
ai_enabled: item.ai_enabled.unwrap_or(false),
|
||||
ai_provider: item.ai_provider,
|
||||
ai_api_base: item.ai_api_base,
|
||||
ai_api_key: item.ai_api_key,
|
||||
ai_chat_model: item.ai_chat_model,
|
||||
ai_embedding_model: item.ai_embedding_model,
|
||||
ai_system_prompt: item.ai_system_prompt,
|
||||
ai_top_k: item.ai_top_k,
|
||||
ai_chunk_size: item.ai_chunk_size,
|
||||
ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
|
||||
ai_chunks_count,
|
||||
ai_local_embedding: ai::local_embedding_label().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn session_status() -> Result<Response> {
|
||||
format::json(AdminSessionResponse {
|
||||
authenticated: is_admin_logged_in(),
|
||||
username: is_admin_logged_in().then(admin_username),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn session_login(Json(payload): Json<AdminLoginPayload>) -> Result<Response> {
|
||||
if !validate_admin_credentials(payload.username.trim(), payload.password.trim()) {
|
||||
return unauthorized("Invalid credentials");
|
||||
}
|
||||
|
||||
set_admin_logged_in(true);
|
||||
|
||||
format::json(AdminSessionResponse {
|
||||
authenticated: true,
|
||||
username: Some(admin_username()),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn session_logout() -> Result<Response> {
|
||||
set_admin_logged_in(false);
|
||||
|
||||
format::json(AdminSessionResponse {
|
||||
authenticated: false,
|
||||
username: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn dashboard(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
check_auth()?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
let total_posts = posts::Entity::find().count(&ctx.db).await?;
|
||||
let total_comments = comments::Entity::find().count(&ctx.db).await?;
|
||||
let pending_comments = comments::Entity::find()
|
||||
.filter(comments::Column::Approved.eq(false))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
let total_categories = crate::models::_entities::categories::Entity::find()
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
let total_tags = crate::models::_entities::tags::Entity::find()
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
let total_reviews = reviews::Entity::find().count(&ctx.db).await?;
|
||||
let total_links = friend_links::Entity::find().count(&ctx.db).await?;
|
||||
let pending_links = friend_links::Entity::find()
|
||||
.filter(friend_links::Column::Status.eq("pending"))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
||||
let site_settings = site_settings::load_current(&ctx).await?;
|
||||
|
||||
let recent_posts = posts::Entity::find()
|
||||
.order_by_desc(posts::Column::CreatedAt)
|
||||
.limit(6)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|post| DashboardPostItem {
|
||||
id: post.id,
|
||||
title: required_text(post.title.as_deref(), "Untitled post"),
|
||||
slug: post.slug,
|
||||
category: required_text(post.category.as_deref(), "Uncategorized"),
|
||||
post_type: required_text(post.post_type.as_deref(), "article"),
|
||||
pinned: post.pinned.unwrap_or(false),
|
||||
created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let pending_comment_rows = comments::Entity::find()
|
||||
.filter(comments::Column::Approved.eq(false))
|
||||
.order_by_desc(comments::Column::CreatedAt)
|
||||
.limit(8)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|comment| DashboardCommentItem {
|
||||
id: comment.id,
|
||||
author: required_text(comment.author.as_deref(), "Anonymous"),
|
||||
post_slug: required_text(comment.post_slug.as_deref(), "unknown-post"),
|
||||
scope: required_text(Some(comment.scope.as_str()), "global"),
|
||||
excerpt: required_text(comment.content.as_deref(), ""),
|
||||
approved: comment.approved.unwrap_or(false),
|
||||
created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let pending_friend_links = friend_links::Entity::find()
|
||||
.filter(friend_links::Column::Status.eq("pending"))
|
||||
.order_by_desc(friend_links::Column::CreatedAt)
|
||||
.limit(6)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|link| DashboardFriendLinkItem {
|
||||
id: link.id,
|
||||
site_name: required_text(link.site_name.as_deref(), "Unnamed site"),
|
||||
site_url: link.site_url,
|
||||
category: required_text(link.category.as_deref(), "Other"),
|
||||
status: required_text(link.status.as_deref(), "pending"),
|
||||
created_at: link.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let recent_reviews = reviews::Entity::find()
|
||||
.order_by_desc(reviews::Column::CreatedAt)
|
||||
.limit(6)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|review| DashboardReviewItem {
|
||||
id: review.id,
|
||||
title: required_text(review.title.as_deref(), "Untitled review"),
|
||||
review_type: required_text(review.review_type.as_deref(), "game"),
|
||||
rating: review.rating.unwrap_or(0),
|
||||
status: required_text(review.status.as_deref(), "completed"),
|
||||
review_date: required_text(review.review_date.as_deref(), ""),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
format::json(AdminDashboardResponse {
|
||||
stats: DashboardStats {
|
||||
total_posts,
|
||||
total_comments,
|
||||
pending_comments,
|
||||
total_categories,
|
||||
total_tags,
|
||||
total_reviews,
|
||||
total_links,
|
||||
pending_links,
|
||||
ai_chunks: ai_chunks_count,
|
||||
ai_enabled: site_settings.ai_enabled.unwrap_or(false),
|
||||
},
|
||||
site: DashboardSiteSummary {
|
||||
site_name: required_text(site_settings.site_name.as_deref(), "Unnamed site"),
|
||||
site_url: required_text(site_settings.site_url.as_deref(), ""),
|
||||
ai_enabled: site_settings.ai_enabled.unwrap_or(false),
|
||||
ai_chunks: ai_chunks_count,
|
||||
ai_last_indexed_at: format_timestamp(
|
||||
site_settings.ai_last_indexed_at,
|
||||
"%Y-%m-%d %H:%M:%S UTC",
|
||||
),
|
||||
},
|
||||
recent_posts,
|
||||
pending_comments: pending_comment_rows,
|
||||
pending_friend_links,
|
||||
recent_reviews,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_site_settings(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
check_auth()?;
|
||||
let current = site_settings::load_current(&ctx).await?;
|
||||
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
||||
format::json(build_settings_response(current, ai_chunks_count))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update_site_settings(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<SiteSettingsPayload>,
|
||||
) -> Result<Response> {
|
||||
check_auth()?;
|
||||
|
||||
let current = site_settings::load_current(&ctx).await?;
|
||||
let mut item = current.into_active_model();
|
||||
params.apply(&mut item);
|
||||
let updated = item.update(&ctx.db).await?;
|
||||
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
||||
|
||||
format::json(build_settings_response(updated, ai_chunks_count))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn reindex_ai(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
check_auth()?;
|
||||
let summary = ai::rebuild_index(&ctx).await?;
|
||||
|
||||
format::json(AdminAiReindexResponse {
|
||||
indexed_chunks: summary.indexed_chunks,
|
||||
last_indexed_at: format_timestamp(summary.last_indexed_at.map(Into::into), "%Y-%m-%d %H:%M:%S UTC"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/admin")
|
||||
.add("/session", get(session_status))
|
||||
.add("/session", delete(session_logout))
|
||||
.add("/session/login", post(session_login))
|
||||
.add("/dashboard", get(dashboard))
|
||||
.add("/site-settings", get(get_site_settings))
|
||||
.add("/site-settings", patch(update_site_settings))
|
||||
.add("/site-settings", put(update_site_settings))
|
||||
.add("/ai/reindex", post(reindex_ai))
|
||||
}
|
||||
Reference in New Issue
Block a user