use axum::{ extract::{Path, Query, State}, response::{Html, IntoResponse}, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebApp { pub id: Uuid, pub name: String, pub slug: String, pub description: Option, pub template: WebAppTemplate, pub status: WebAppStatus, pub config: WebAppConfig, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub enum WebAppTemplate { #[default] Blank, Landing, Dashboard, Form, Portal, Custom(String), } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub enum WebAppStatus { #[default] Draft, Published, Archived, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WebAppConfig { pub theme: String, pub layout: String, pub auth_required: bool, pub custom_domain: Option, pub meta_tags: HashMap, pub scripts: Vec, pub styles: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebAppPage { pub id: Uuid, pub app_id: Uuid, pub path: String, pub title: String, pub content: String, pub layout: Option, pub is_index: bool, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebAppComponent { pub id: Uuid, pub app_id: Uuid, pub name: String, pub component_type: ComponentType, pub props: serde_json::Value, pub children: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ComponentType { Container, Text, Image, Button, Form, Input, Table, Chart, Custom(String), } pub struct WebaState { apps: RwLock>, pages: RwLock>, _components: RwLock>, } impl WebaState { pub fn new() -> Self { Self { apps: RwLock::new(HashMap::new()), pages: RwLock::new(HashMap::new()), _components: RwLock::new(HashMap::new()), } } } impl Default for WebaState { fn default() -> Self { Self::new() } } #[derive(Debug, Deserialize)] pub struct CreateAppRequest { pub name: String, pub description: Option, pub template: Option, } #[derive(Debug, Deserialize)] pub struct UpdateAppRequest { pub name: Option, pub description: Option, pub status: Option, pub config: Option, } #[derive(Debug, Deserialize)] pub struct CreatePageRequest { pub path: String, pub title: String, pub content: String, pub layout: Option, pub is_index: bool, } #[derive(Debug, Deserialize)] pub struct ListQuery { pub limit: Option, pub offset: Option, pub status: Option, } pub fn configure_routes(state: Arc) -> Router { Router::new() .route("/apps", get(list_apps).post(create_app)) .route("/apps/:id", get(get_app).put(update_app).delete(delete_app)) .route("/apps/:id/pages", get(list_pages).post(create_page)) .route( "/apps/:id/pages/:page_id", get(get_page).put(update_page).delete(delete_page), ) .route("/apps/:id/publish", post(publish_app)) .route("/apps/:id/preview", get(preview_app)) .route("/render/:slug", get(render_app)) .route("/render/:slug/*path", get(render_page)) .with_state(state) } async fn list_apps( State(state): State>, Query(query): Query, ) -> Json> { let apps = state.apps.read().await; let mut result: Vec = apps.values().cloned().collect(); if let Some(status) = query.status { result.retain(|app| match (&app.status, status.as_str()) { (WebAppStatus::Draft, "draft") => true, (WebAppStatus::Published, "published") => true, (WebAppStatus::Archived, "archived") => true, _ => false, }); } result.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); let offset = query.offset.unwrap_or(0); let limit = query.limit.unwrap_or(50); let result: Vec = result.into_iter().skip(offset).take(limit).collect(); Json(result) } async fn create_app( State(state): State>, Json(req): Json, ) -> Json { let now = chrono::Utc::now(); let id = Uuid::new_v4(); let slug = slugify(&req.name); let app = WebApp { id, name: req.name, slug, description: req.description, template: req.template.unwrap_or_default(), status: WebAppStatus::Draft, config: WebAppConfig::default(), created_at: now, updated_at: now, }; let mut apps = state.apps.write().await; apps.insert(id, app.clone()); Json(app) } async fn get_app( State(state): State>, Path(id): Path, ) -> Result, axum::http::StatusCode> { let apps = state.apps.read().await; apps.get(&id) .cloned() .map(Json) .ok_or(axum::http::StatusCode::NOT_FOUND) } async fn update_app( State(state): State>, Path(id): Path, Json(req): Json, ) -> Result, axum::http::StatusCode> { let mut apps = state.apps.write().await; let app = apps.get_mut(&id).ok_or(axum::http::StatusCode::NOT_FOUND)?; if let Some(name) = req.name { app.name = name.clone(); app.slug = slugify(&name); } if let Some(description) = req.description { app.description = Some(description); } if let Some(status) = req.status { app.status = status; } if let Some(config) = req.config { app.config = config; } app.updated_at = chrono::Utc::now(); Ok(Json(app.clone())) } async fn delete_app( State(state): State>, Path(id): Path, ) -> axum::http::StatusCode { let mut apps = state.apps.write().await; let mut pages = state.pages.write().await; pages.retain(|_, page| page.app_id != id); if apps.remove(&id).is_some() { axum::http::StatusCode::NO_CONTENT } else { axum::http::StatusCode::NOT_FOUND } } async fn list_pages( State(state): State>, Path(app_id): Path, ) -> Json> { let pages = state.pages.read().await; let result: Vec = pages .values() .filter(|p| p.app_id == app_id) .cloned() .collect(); Json(result) } async fn create_page( State(state): State>, Path(app_id): Path, Json(req): Json, ) -> Result, axum::http::StatusCode> { let apps = state.apps.read().await; if !apps.contains_key(&app_id) { return Err(axum::http::StatusCode::NOT_FOUND); } drop(apps); let now = chrono::Utc::now(); let id = Uuid::new_v4(); let page = WebAppPage { id, app_id, path: req.path, title: req.title, content: req.content, layout: req.layout, is_index: req.is_index, created_at: now, updated_at: now, }; let mut pages = state.pages.write().await; pages.insert(id, page.clone()); Ok(Json(page)) } async fn get_page( State(state): State>, Path((app_id, page_id)): Path<(Uuid, Uuid)>, ) -> Result, axum::http::StatusCode> { let pages = state.pages.read().await; pages .get(&page_id) .filter(|p| p.app_id == app_id) .cloned() .map(Json) .ok_or(axum::http::StatusCode::NOT_FOUND) } async fn update_page( State(state): State>, Path((app_id, page_id)): Path<(Uuid, Uuid)>, Json(req): Json, ) -> Result, axum::http::StatusCode> { let mut pages = state.pages.write().await; let page = pages .get_mut(&page_id) .filter(|p| p.app_id == app_id) .ok_or(axum::http::StatusCode::NOT_FOUND)?; page.path = req.path; page.title = req.title; page.content = req.content; page.layout = req.layout; page.is_index = req.is_index; page.updated_at = chrono::Utc::now(); Ok(Json(page.clone())) } async fn delete_page( State(state): State>, Path((app_id, page_id)): Path<(Uuid, Uuid)>, ) -> axum::http::StatusCode { let mut pages = state.pages.write().await; let exists = pages .get(&page_id) .map(|p| p.app_id == app_id) .unwrap_or(false); if exists { pages.remove(&page_id); axum::http::StatusCode::NO_CONTENT } else { axum::http::StatusCode::NOT_FOUND } } async fn publish_app( State(state): State>, Path(id): Path, ) -> Result, axum::http::StatusCode> { let mut apps = state.apps.write().await; let app = apps.get_mut(&id).ok_or(axum::http::StatusCode::NOT_FOUND)?; app.status = WebAppStatus::Published; app.updated_at = chrono::Utc::now(); Ok(Json(app.clone())) } async fn preview_app( State(state): State>, Path(id): Path, ) -> Result, axum::http::StatusCode> { let apps = state.apps.read().await; let app = apps.get(&id).ok_or(axum::http::StatusCode::NOT_FOUND)?; let pages = state.pages.read().await; let index_page = pages.values().find(|p| p.app_id == id && p.is_index); let content = index_page .map(|p| p.content.clone()) .unwrap_or_else(|| "

No content yet

".to_string()); let html = render_html(app, &content); Ok(Html(html)) } async fn render_app( State(state): State>, Path(slug): Path, ) -> Result { let apps = state.apps.read().await; let app = apps .values() .find(|a| a.slug == slug && matches!(a.status, WebAppStatus::Published)) .ok_or(axum::http::StatusCode::NOT_FOUND)? .clone(); drop(apps); let pages = state.pages.read().await; let index_page = pages.values().find(|p| p.app_id == app.id && p.is_index); let content = index_page .map(|p| p.content.clone()) .unwrap_or_else(|| "

Page not found

".to_string()); let html = render_html(&app, &content); Ok(Html(html)) } async fn render_page( State(state): State>, Path((slug, path)): Path<(String, String)>, ) -> Result { let apps = state.apps.read().await; let app = apps .values() .find(|a| a.slug == slug && matches!(a.status, WebAppStatus::Published)) .ok_or(axum::http::StatusCode::NOT_FOUND)? .clone(); drop(apps); let normalized_path = format!("/{}", path.trim_start_matches('/')); let pages = state.pages.read().await; let page = pages .values() .find(|p| p.app_id == app.id && p.path == normalized_path); let content = page .map(|p| p.content.clone()) .unwrap_or_else(|| "

Page not found

".to_string()); let html = render_html(&app, &content); Ok(Html(html)) } fn render_html(app: &WebApp, content: &str) -> String { let meta_tags: String = app .config .meta_tags .iter() .map(|(k, v)| format!("", k, v)) .collect::>() .join("\n "); let scripts: String = app .config .scripts .iter() .map(|s| format!("", s)) .collect::>() .join("\n "); let styles: String = app .config .styles .iter() .map(|s| format!("", s)) .collect::>() .join("\n "); format!( r#" {} {} {} {} {} "#, app.name, meta_tags, styles, content, scripts ) } pub fn slugify(s: &str) -> String { s.to_lowercase() .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect::() .split('-') .filter(|s| !s.is_empty()) .collect::>() .join("-") } pub fn init() { log::info!("WEBA module initialized"); }