|
|
|
|
@@ -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))
|
|
|
|
|
}
|