use livevue_rs::{ AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore, format_patch_elements, format_patch_signals, sse_handler, }; use axum::extract::{Query as AxumQuery, State}; use axum::http::header; use axum::response::{Html, IntoResponse}; use axum::routing::{get, post}; use axum::{Json, Router}; use uuid::Uuid; // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- /// Build the axum Router with all routes. /// /// Lesson from v0.0: handlers live here next to the components they call, /// not in a separate routes.rs with complex trait object signatures. 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("/render", post(render_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 // --------------------------------------------------------------------------- /// Wrap rendered component HTML in a full HTML document. /// /// Lesson from v0.0: Maud can't handle DataStar's attribute syntax /// (e.g. `data-on-signal-change__debounce.300ms`). The document shell uses /// format! / raw string. Maud is for component interiors only. /// /// DataStar v1 contract (verified against https://data-star.dev/reference): /// - `data-signals` — initialize signals (JS object literal syntax) /// - `data-on-signal-change` — fires when any signal changes /// - `__debounce.300ms` — modifier: debounce 300ms /// - `@post('/render')` — send all non-_ signals as JSON body /// - `@get('/sse?conn=...')` — open long-lived SSE pipe /// - `data-on-load` — fires when element enters DOM /// /// KNOWN v0 QUIRK: Nav buttons use `$tree = {...}; @post('/render')`, /// which fires an immediate POST. The signal change handler then fires a /// second debounced POST 300ms later. This double-fire is harmless (same /// HTML both times) but wasteful. Phase 2 could fix this with request /// deduplication or by switching nav to only mutate the signal and relying /// solely on the auto-poster. fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> String { format!( r#" LiveVue.rs — Todo
{inner}
"#, css = INLINE_CSS, signals = initial_signals, conn = conn_id, inner = inner_html, ) } /// Minimal CSS for the todo app. 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; } .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 // --------------------------------------------------------------------------- /// Match a page name (from the `tree` signal) to its component function. /// /// This is a plain match statement — no ComponentRegistry, no trait objects, /// no async lifetime gymnastics. The right call for a v0 with two pages. 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 => maud::html! { div { h1 { "404 — Unknown page" } p { "No component registered for: " (other) } button.nav-link data-on-click="$tree = {page: 'TodoList'}; @post('/render')" { "Go to Todos" } } }, }; Ok(markup.into_string()) } /// Shared render pipeline used by both initial page load and POST /render. /// /// Returns `(rendered_html, subscription_keys, signals_json)`. async fn render_page( db: &sqlx::SqlitePool, conn_id: Uuid, signals: SignalStore, tree: ComponentTree, ) -> anyhow::Result<(String, Vec, String)> { let signals_json = signals.to_json_string(); let mut cx = RenderContext::new(conn_id, signals, tree, db.clone()); let html = resolve_and_render(&mut cx).await?; let keys = cx.take_subscription_keys(); Ok((html, keys, signals_json)) } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- /// `GET /` — Initial page load for the todo list. async fn initial_page_load(State(state): State) -> impl IntoResponse { let conn_id = Uuid::new_v4(); // Build initial signals 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!("")), ]); let initial_signals_attr = signals.to_data_signals_attr(); match render_page(&state.db, conn_id, signals, tree).await { Ok((html, _keys, _signals_json)) => { // Keys and signals_json are stored when SSE connects Html(document_shell(conn_id, &initial_signals_attr, &html)) } Err(e) => Html(document_shell( conn_id, &initial_signals_attr, &format!("

Render error: {}

", e), )), } } /// `GET /about` — Initial page load for the about page. 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()), ]); let initial_signals_attr = signals.to_data_signals_attr(); match render_page(&state.db, conn_id, signals, tree).await { Ok((html, _keys, _signals_json)) => { Html(document_shell(conn_id, &initial_signals_attr, &html)) } Err(e) => Html(document_shell( conn_id, &initial_signals_attr, &format!("

Render error: {}

", e), )), } } /// `POST /render` — Signal-triggered rerender. /// /// DataStar sends all non-`_` signals as JSON body when any signal changes /// (triggered by `data-on-signal-change` + `@post('/render')`). /// /// Returns `Content-Type: text/event-stream` with a single /// `datastar-patch-elements` event containing the full page HTML. async fn render_handler( State(state): State, Json(body): Json, ) -> impl IntoResponse { let signals = SignalStore::from_json(body); // Extract connection id let conn_id: Uuid = signals .get("connId") .and_then(|v| v.as_str()) .and_then(|s| s.parse().ok()) .unwrap_or_else(Uuid::new_v4); // Extract component tree let tree: ComponentTree = signals .get("tree") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); match render_page(&state.db, conn_id, signals, tree).await { Ok((html, keys, signals_json)) => { // Update connection state state.connections.update_signals(&conn_id, signals_json); state.subscriptions.update(conn_id, keys); let sse_body = format_patch_elements(&html); ( [(header::CONTENT_TYPE, "text/event-stream")], sse_body, ) .into_response() } Err(e) => { let error_html = format!(r#"

Render error: {}

"#, e); let sse_body = format_patch_elements(&error_html); ( [(header::CONTENT_TYPE, "text/event-stream")], sse_body, ) .into_response() } } } /// Query params for action endpoints that need an entity id. #[derive(serde::Deserialize)] struct ActionIdParams { id: i64, } /// Helper: extract common fields from signal body, run a mutation, then rerender. /// /// Returns an SSE response with the rerendered page. async fn action_then_render( state: &AppState, body: serde_json::Value, mutation: impl std::future::Future> + Send, patch_signals: Option<&str>, ) -> impl IntoResponse { let signals = SignalStore::from_json(body); let conn_id: Uuid = signals .get("connId") .and_then(|v| v.as_str()) .and_then(|s| s.parse().ok()) .unwrap_or_else(Uuid::new_v4); let tree: ComponentTree = signals .get("tree") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); // Run the mutation if let Err(e) = mutation.await { let error_html = format!(r#"

Action error: {}

"#, e); return ( [(header::CONTENT_TYPE, "text/event-stream")], format_patch_elements(&error_html), ) .into_response(); } // Rerender match render_page(&state.db, conn_id, signals, tree).await { Ok((html, keys, signals_json)) => { state.connections.update_signals(&conn_id, signals_json); state.subscriptions.update(conn_id, keys); let mut sse_body = String::new(); // Optionally patch signals (e.g. clear the input) if let Some(ps) = patch_signals { sse_body.push_str(&format_patch_signals(ps)); } sse_body.push_str(&format_patch_elements(&html)); ( [(header::CONTENT_TYPE, "text/event-stream")], sse_body, ) .into_response() } Err(e) => { let error_html = format!(r#"

Render error: {}

"#, e); ( [(header::CONTENT_TYPE, "text/event-stream")], format_patch_elements(&error_html), ) .into_response() } } } /// `POST /action/add_todo` — Create a new todo, then rerender. /// /// Reads the `newTodo` signal from the POST body, inserts it, and sends /// back a `datastar-patch-signals` event to clear the input followed by /// a `datastar-patch-elements` event with the rerendered page. 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.db.clone(); action_then_render( &state, body, async move { if !title.is_empty() { crate::queries::create_todo(&db, &title).await?; } Ok(()) }, Some("{newTodo: ''}"), // Clear the input after adding ) .await } /// `POST /action/toggle_todo?id=` — Toggle a todo's completed status. async fn action_toggle_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { let db = state.db.clone(); let id = params.id; action_then_render( &state, body, async move { crate::queries::toggle_todo(&db, id).await }, None, ) .await } /// `POST /action/delete_todo?id=` — Delete a todo. async fn action_delete_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { let db = state.db.clone(); let id = params.id; action_then_render( &state, body, async move { crate::queries::delete_todo(&db, id).await }, None, ) .await }