use livevue_rs::{ AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore, ingest_signals, sse_handler, }; use axum::extract::{Query as AxumQuery, State}; use axum::response::{Html, IntoResponse}; use axum::routing::{get, post}; use axum::{Json, Router}; use axum::http::StatusCode; use uuid::Uuid; // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- pub fn router(state: SharedState) -> Router { Router::new() .route("/", get(initial_page_load)) .route("/about", get(initial_about_load)) .route("/sse", get(sse_handler)) .route("/action/add_todo", post(action_add_todo)) .route("/action/toggle_todo", post(action_toggle_todo)) .route("/action/delete_todo", post(action_delete_todo)) .with_state(state) } // --------------------------------------------------------------------------- // Document shell // --------------------------------------------------------------------------- fn document_shell(conn_id: Uuid) -> String { format!( r#" LiveVue.rs — Todo
"#, css = INLINE_CSS, conn = conn_id, ) } /// Used by the fanout task to wrap rendered inner HTML for morphing. pub fn app_wrapper(conn_id: Uuid, signals: &str, inner_html: &str) -> String { format!( r#"
{inner}

        
"#, signals = signals, inner = inner_html, ) } const INLINE_CSS: &str = r#" body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; background: #fafafa; color: #333; } h1 { color: #2563eb; } nav { margin-bottom: 1.5rem; } .nav-link { background: none; border: none; color: #2563eb; cursor: pointer; text-decoration: underline; font-size: inherit; padding: 0; } .nav-current { font-weight: bold; } .clock { font-variant-numeric: tabular-nums; color: #555; font-size: 0.95rem; margin-bottom: 1rem; } .add-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; } .add-form input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; } .add-form button { padding: 0.5rem 1rem; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer; } .add-form button:disabled { opacity: 0.5; cursor: not-allowed; } .stats { color: #666; font-size: 0.9rem; } .todo-list { list-style: none; padding: 0; } .todo-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #eee; } .todo-title { flex: 1; } .todo-title.completed { text-decoration: line-through; color: #999; } .delete-btn { background: none; border: none; color: #ef4444; cursor: pointer; font-size: 1.2rem; padding: 0 0.25rem; } .empty { color: #999; font-style: italic; } .about-content p { line-height: 1.6; } "#; // --------------------------------------------------------------------------- // Page resolution + render pipeline // --------------------------------------------------------------------------- pub async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result { let markup = match cx.tree.page.as_str() { "TodoList" => crate::todo_list::todo_list(cx).await?, "About" => crate::about::about(cx).await?, other => crate::todo_list::todo_list(cx).await? }; Ok(markup.into_string()) } fn extract_conn_and_tree(signals: &SignalStore) -> (Uuid, ComponentTree) { let conn_id = signals .get("connId") .and_then(|v| v.as_str()) .and_then(|s| s.parse().ok()) .unwrap_or_else(Uuid::new_v4); let tree = signals .get("tree") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); (conn_id, tree) } // --------------------------------------------------------------------------- // Initial page load handlers // --------------------------------------------------------------------------- async fn initial_page_load(State(state): State) -> impl IntoResponse { let conn_id = Uuid::new_v4(); // Seed the initial signal state so the SSE handler can trigger the first // render as soon as the pipe opens, without a client round-trip. let tree = ComponentTree { page: "TodoList".into(), ..Default::default() }; let signals = SignalStore::from_pairs(vec![ ("connId".into(), serde_json::json!(conn_id.to_string())), ("tree".into(), serde_json::to_value(&tree).unwrap()), ("newTodo".into(), serde_json::json!("")), ]); state.connections.update_signals(&conn_id, signals.to_json_string()); Html(document_shell(conn_id)) } async fn initial_about_load(State(state): State) -> impl IntoResponse { let conn_id = Uuid::new_v4(); let tree = ComponentTree { page: "About".into(), ..Default::default() }; let signals = SignalStore::from_pairs(vec![ ("connId".into(), serde_json::json!(conn_id.to_string())), ("tree".into(), serde_json::to_value(&tree).unwrap()), ]); state.connections.update_signals(&conn_id, signals.to_json_string()); Html(document_shell(conn_id)) } // --------------------------------------------------------------------------- // Action handlers // --------------------------------------------------------------------------- #[derive(serde::Deserialize)] struct ActionIdParams { id: i64, } /// Ingest signals, run a mutation, then publish an invalidation event so the /// fanout task handles the rerender. Returns 202 Accepted. async fn action_then_publish( state: &AppState, body: serde_json::Value, mutation: impl std::future::Future> + Send, invalidation_key: CacheKey, ) -> StatusCode { let signals = ingest_signals(state, extract_conn_and_tree(&SignalStore::from_json(body.clone())).0, body); let _ = signals; // signals are stored; mutation drives the rerender if let Err(e) = mutation.await { eprintln!("Action mutation error: {}", e); return StatusCode::INTERNAL_SERVER_ERROR; } state.store.publish(invalidation_key); StatusCode::ACCEPTED } async fn action_add_todo( State(state): State, Json(body): Json, ) -> impl IntoResponse { let title = body .get("newTodo") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); let db = state.store.db.clone(); action_then_publish( &state, body, async move { if !title.is_empty() { crate::queries::create_todo(&db, &title).await?; } Ok(()) }, CacheKey::Table { table: "todos" }, ) .await } async fn action_toggle_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { let db = state.store.db.clone(); let id = params.id; action_then_publish( &state, body, async move { crate::queries::toggle_todo(&db, id).await }, CacheKey::Table { table: "todos" }, ) .await } async fn action_delete_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { let db = state.store.db.clone(); let id = params.id; action_then_publish( &state, body, async move { crate::queries::delete_todo(&db, id).await }, CacheKey::Table { table: "todos" }, ) .await }