use livevue_rs::{ 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, } async fn action_add_todo( State(state): State, Json(body): Json, ) -> impl IntoResponse { let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0; ingest_signals(&state, conn_id, body.clone()); let title = body .get("newTodo") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); if title.is_empty() { return StatusCode::ACCEPTED; } if let Err(e) = state.store.exec(crate::queries::CreateTodo { title }).await { eprintln!("action_add_todo error: {e}"); return StatusCode::INTERNAL_SERVER_ERROR; } StatusCode::ACCEPTED } async fn action_toggle_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0; ingest_signals(&state, conn_id, body); if let Err(e) = state.store.exec(crate::queries::ToggleTodo { id: params.id }).await { eprintln!("action_toggle_todo error: {e}"); return StatusCode::INTERNAL_SERVER_ERROR; } StatusCode::ACCEPTED } async fn action_delete_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0; ingest_signals(&state, conn_id, body); if let Err(e) = state.store.exec(crate::queries::DeleteTodo { id: params.id }).await { eprintln!("action_delete_todo error: {e}"); return StatusCode::INTERNAL_SERVER_ERROR; } StatusCode::ACCEPTED }