incremental progess
This commit is contained in:
@@ -16,7 +16,7 @@ pub async fn about(_cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
|||||||
|
|
||||||
nav {
|
nav {
|
||||||
button.nav-link
|
button.nav-link
|
||||||
data-on-click="$tree = {page: 'TodoList'}; @post('/render')" {
|
data-on:click="$tree = {page: 'TodoList'}; @post('/render')" {
|
||||||
"Todos"
|
"Todos"
|
||||||
}
|
}
|
||||||
" | "
|
" | "
|
||||||
|
|||||||
+88
-137
@@ -1,23 +1,19 @@
|
|||||||
use livevue_rs::{
|
use livevue_rs::{
|
||||||
AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore,
|
AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore,
|
||||||
format_patch_elements, format_patch_signals, sse_handler,
|
format_patch_elements, sse_handler,
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum::extract::{Query as AxumQuery, State};
|
use axum::extract::{Query as AxumQuery, State};
|
||||||
use axum::http::header;
|
|
||||||
use axum::response::{Html, IntoResponse};
|
use axum::response::{Html, IntoResponse};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
|
use axum::http::StatusCode;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Router
|
// 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 {
|
pub fn router(state: SharedState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(initial_page_load))
|
.route("/", get(initial_page_load))
|
||||||
@@ -34,27 +30,13 @@ pub fn router(state: SharedState) -> Router {
|
|||||||
// Document shell
|
// Document shell
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Wrap rendered component HTML in a full HTML document.
|
/// Full HTML document for initial page load.
|
||||||
///
|
///
|
||||||
/// Lesson from v0.0: Maud can't handle DataStar's attribute syntax
|
/// DataStar v1 contract:
|
||||||
/// (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-signals` — initialize signals (JS object literal syntax)
|
||||||
/// - `data-on-signal-patch` — fires when any signal is patched (renamed from
|
/// - `data-on-signal-patch__debounce.300ms` — fires when any signal is patched,
|
||||||
/// `data-on-signal-change` which was removed in RC8)
|
/// debounced 300ms, posts all non-_ signals as JSON body to /render
|
||||||
/// - `__debounce.300ms` — modifier: debounce 300ms
|
/// - `data-init` — fires when element enters DOM, opens long-lived SSE pipe
|
||||||
/// - `@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 {
|
fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"<!DOCTYPE html>
|
r#"<!DOCTYPE html>
|
||||||
@@ -69,16 +51,29 @@ fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> Str
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"
|
{app}
|
||||||
data-signals='{signals}'
|
|
||||||
data-on-signal-patch__debounce.300ms="@post('/render')"
|
|
||||||
data-on-load="@get('/sse?conn={conn}')">
|
|
||||||
{inner}
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>"#,
|
</html>"#,
|
||||||
css = INLINE_CSS,
|
css = INLINE_CSS,
|
||||||
signals = initial_signals,
|
app = app_wrapper(conn_id, initial_signals, inner_html),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `<div id="app">` wrapper with DataStar bindings.
|
||||||
|
///
|
||||||
|
/// Extracted separately so `/render` can return just this element as
|
||||||
|
/// `text/html` — DataStar morphs it into the existing DOM by id.
|
||||||
|
fn app_wrapper(conn_id: Uuid, signals: &str, inner_html: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<div id="app"
|
||||||
|
data-signals='{signals}'
|
||||||
|
data-on-signal-patch="@post('/render')"
|
||||||
|
data-on-init="@get('/sse?conn={conn}')">
|
||||||
|
{inner}
|
||||||
|
<pre data-json-signals></pre>
|
||||||
|
</div>"#,
|
||||||
|
// Could __debounce.300ms on the signal patch
|
||||||
|
signals = signals,
|
||||||
conn = conn_id,
|
conn = conn_id,
|
||||||
inner = inner_html,
|
inner = inner_html,
|
||||||
)
|
)
|
||||||
@@ -109,10 +104,6 @@ const INLINE_CSS: &str = r#"
|
|||||||
// Page resolution
|
// 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<String> {
|
async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result<String> {
|
||||||
let markup = match cx.tree.page.as_str() {
|
let markup = match cx.tree.page.as_str() {
|
||||||
"TodoList" => crate::todo_list::todo_list(cx).await?,
|
"TodoList" => crate::todo_list::todo_list(cx).await?,
|
||||||
@@ -131,9 +122,7 @@ async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result<String> {
|
|||||||
Ok(markup.into_string())
|
Ok(markup.into_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared render pipeline used by both initial page load and POST /render.
|
/// Shared render pipeline. Returns `(rendered_inner_html, subscription_keys, signals_json)`.
|
||||||
///
|
|
||||||
/// Returns `(rendered_html, subscription_keys, signals_json)`.
|
|
||||||
async fn render_page(
|
async fn render_page(
|
||||||
db: &sqlx::SqlitePool,
|
db: &sqlx::SqlitePool,
|
||||||
conn_id: Uuid,
|
conn_id: Uuid,
|
||||||
@@ -147,6 +136,22 @@ async fn render_page(
|
|||||||
Ok((html, keys, signals_json))
|
Ok((html, keys, signals_json))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract conn_id and tree from a signal store.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -155,7 +160,6 @@ async fn render_page(
|
|||||||
async fn initial_page_load(State(state): State<SharedState>) -> impl IntoResponse {
|
async fn initial_page_load(State(state): State<SharedState>) -> impl IntoResponse {
|
||||||
let conn_id = Uuid::new_v4();
|
let conn_id = Uuid::new_v4();
|
||||||
|
|
||||||
// Build initial signals
|
|
||||||
let tree = ComponentTree {
|
let tree = ComponentTree {
|
||||||
page: "TodoList".into(),
|
page: "TodoList".into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -170,7 +174,6 @@ async fn initial_page_load(State(state): State<SharedState>) -> impl IntoRespons
|
|||||||
|
|
||||||
match render_page(&state.db, conn_id, signals, tree).await {
|
match render_page(&state.db, conn_id, signals, tree).await {
|
||||||
Ok((html, _keys, _signals_json)) => {
|
Ok((html, _keys, _signals_json)) => {
|
||||||
// Keys and signals_json are stored when SSE connects
|
|
||||||
Html(document_shell(conn_id, &initial_signals_attr, &html))
|
Html(document_shell(conn_id, &initial_signals_attr, &html))
|
||||||
}
|
}
|
||||||
Err(e) => Html(document_shell(
|
Err(e) => Html(document_shell(
|
||||||
@@ -210,52 +213,27 @@ async fn initial_about_load(State(state): State<SharedState>) -> impl IntoRespon
|
|||||||
|
|
||||||
/// `POST /render` — Signal-triggered rerender.
|
/// `POST /render` — Signal-triggered rerender.
|
||||||
///
|
///
|
||||||
/// DataStar sends all non-`_` signals as JSON body when any signal changes
|
/// DataStar sends all non-`_` signals as JSON body on every signal patch.
|
||||||
/// (triggered by `data-on-signal-patch` + `@post('/render')`).
|
/// Returns `text/html` with the full `<div id="app">` wrapper — DataStar
|
||||||
///
|
/// morphs it into the existing DOM by matching the element id.
|
||||||
/// Returns `Content-Type: text/event-stream` with a single
|
|
||||||
/// `datastar-patch-elements` event containing the full page HTML.
|
|
||||||
async fn render_handler(
|
async fn render_handler(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let signals = SignalStore::from_json(body);
|
let signals = SignalStore::from_json(body);
|
||||||
|
let (conn_id, tree) = extract_conn_and_tree(&signals);
|
||||||
|
|
||||||
// Extract connection id
|
match render_page(&state.db, conn_id, signals.clone(), tree).await {
|
||||||
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)) => {
|
Ok((html, keys, signals_json)) => {
|
||||||
// Update connection state
|
state.connections.update_signals(&conn_id, signals_json.clone());
|
||||||
state.connections.update_signals(&conn_id, signals_json);
|
|
||||||
state.subscriptions.update(conn_id, keys);
|
state.subscriptions.update(conn_id, keys);
|
||||||
|
Html(app_wrapper(conn_id, &signals_json, &html))
|
||||||
let sse_body = format_patch_elements(&html);
|
|
||||||
(
|
|
||||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
||||||
sse_body,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let error_html = format!(r#"<div id="app"><p>Render error: {}</p></div>"#, e);
|
|
||||||
let sse_body = format_patch_elements(&error_html);
|
|
||||||
(
|
|
||||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
||||||
sse_body,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
|
Err(e) => Html(app_wrapper(
|
||||||
|
conn_id,
|
||||||
|
&signals.to_json_string(),
|
||||||
|
&format!("<p>Render error: {}</p>", e),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,73 +243,43 @@ struct ActionIdParams {
|
|||||||
id: i64,
|
id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: extract common fields from signal body, run a mutation, then rerender.
|
/// Helper: run a mutation, push the rerendered page down the SSE pipe, return 200.
|
||||||
///
|
///
|
||||||
/// Returns an SSE response with the rerendered page.
|
/// Actions do not return a body — the result comes down the SSE pipe as a
|
||||||
async fn action_then_render(
|
/// `datastar-patch-elements` event.
|
||||||
|
async fn action_then_push(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
body: serde_json::Value,
|
signals: SignalStore,
|
||||||
mutation: impl std::future::Future<Output = anyhow::Result<()>> + Send,
|
mutation: impl std::future::Future<Output = anyhow::Result<()>> + Send,
|
||||||
patch_signals: Option<&str>,
|
) -> StatusCode {
|
||||||
) -> impl IntoResponse {
|
let (conn_id, tree) = extract_conn_and_tree(&signals);
|
||||||
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 {
|
if let Err(e) = mutation.await {
|
||||||
let error_html = format!(r#"<div id="app"><p>Action error: {}</p></div>"#, e);
|
eprintln!("Action error for {}: {}", conn_id, e);
|
||||||
return (
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
||||||
format_patch_elements(&error_html),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rerender
|
|
||||||
match render_page(&state.db, conn_id, signals, tree).await {
|
match render_page(&state.db, conn_id, signals, tree).await {
|
||||||
Ok((html, keys, signals_json)) => {
|
Ok((html, keys, signals_json)) => {
|
||||||
state.connections.update_signals(&conn_id, signals_json);
|
state.connections.update_signals(&conn_id, signals_json.clone());
|
||||||
state.subscriptions.update(conn_id, keys);
|
state.subscriptions.update(conn_id, keys);
|
||||||
|
|
||||||
let mut sse_body = String::new();
|
if let Some(sender) = state.connections.get_sender(&conn_id) {
|
||||||
// Optionally patch signals (e.g. clear the input)
|
let event = format_patch_elements(&app_wrapper(conn_id, &signals_json, &html));
|
||||||
if let Some(ps) = patch_signals {
|
// Non-blocking: if the pipe is closed the cleanup task handles it
|
||||||
sse_body.push_str(&format_patch_signals(ps));
|
let _ = sender.try_send(event);
|
||||||
}
|
}
|
||||||
sse_body.push_str(&format_patch_elements(&html));
|
|
||||||
|
|
||||||
(
|
StatusCode::OK
|
||||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
||||||
sse_body,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error_html = format!(r#"<div id="app"><p>Render error: {}</p></div>"#, e);
|
eprintln!("Render error for {}: {}", conn_id, e);
|
||||||
(
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
[(header::CONTENT_TYPE, "text/event-stream")],
|
|
||||||
format_patch_elements(&error_html),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /action/add_todo` — Create a new todo, then rerender.
|
/// `POST /action/add_todo` — Insert a new todo, push rerender down SSE pipe.
|
||||||
///
|
|
||||||
/// 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(
|
async fn action_add_todo(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
@@ -343,51 +291,54 @@ async fn action_add_todo(
|
|||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
let signals = SignalStore::from_json(body);
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
action_then_render(
|
|
||||||
|
action_then_push(
|
||||||
&state,
|
&state,
|
||||||
body,
|
signals,
|
||||||
async move {
|
async move {
|
||||||
if !title.is_empty() {
|
if !title.is_empty() {
|
||||||
crate::queries::create_todo(&db, &title).await?;
|
crate::queries::create_todo(&db, &title).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
Some("{newTodo: ''}"), // Clear the input after adding
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /action/toggle_todo?id=<i64>` — Toggle a todo's completed status.
|
/// `POST /action/toggle_todo?id=<i64>` — Toggle completion, push rerender.
|
||||||
async fn action_toggle_todo(
|
async fn action_toggle_todo(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let signals = SignalStore::from_json(body);
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
let id = params.id;
|
let id = params.id;
|
||||||
action_then_render(
|
|
||||||
|
action_then_push(
|
||||||
&state,
|
&state,
|
||||||
body,
|
signals,
|
||||||
async move { crate::queries::toggle_todo(&db, id).await },
|
async move { crate::queries::toggle_todo(&db, id).await },
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /action/delete_todo?id=<i64>` — Delete a todo.
|
/// `POST /action/delete_todo?id=<i64>` — Delete a todo, push rerender.
|
||||||
async fn action_delete_todo(
|
async fn action_delete_todo(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let signals = SignalStore::from_json(body);
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
let id = params.id;
|
let id = params.id;
|
||||||
action_then_render(
|
|
||||||
|
action_then_push(
|
||||||
&state,
|
&state,
|
||||||
body,
|
signals,
|
||||||
async move { crate::queries::delete_todo(&db, id).await },
|
async move { crate::queries::delete_todo(&db, id).await },
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ use maud::{html, Markup};
|
|||||||
/// - `cx.run()` for data fetching
|
/// - `cx.run()` for data fetching
|
||||||
/// - `cx.signal()` with `global()` scoping + `Signal::val` in expressions
|
/// - `cx.signal()` with `global()` scoping + `Signal::val` in expressions
|
||||||
/// - `cx.signal()` with `mangle().client_only()` scoping (in todo_item)
|
/// - `cx.signal()` with `mangle().client_only()` scoping (in todo_item)
|
||||||
/// - DataStar `data-bind`, `data-on-click`, `data-attr` attributes
|
/// - DataStar `data-bind`, `data-on:click`, `data-attr` attributes
|
||||||
/// - `@post()` actions for mutations
|
/// - `@post()` actions for mutations
|
||||||
pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||||||
// Fetch all todos
|
// Fetch all todos
|
||||||
@@ -34,7 +34,7 @@ pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
|||||||
span.nav-current { "Todos" }
|
span.nav-current { "Todos" }
|
||||||
" | "
|
" | "
|
||||||
button.nav-link
|
button.nav-link
|
||||||
data-on-click="$tree = {page: 'About'}; @post('/render')" {
|
data-on:click="$tree = {page: 'About'};" {
|
||||||
"About"
|
"About"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
|||||||
data-bind="newTodo"
|
data-bind="newTodo"
|
||||||
;
|
;
|
||||||
button
|
button
|
||||||
data-on-click="@post('/action/add_todo')"
|
data-on:click="@post('/action/add_todo')"
|
||||||
data-attr=(format!("{{disabled: {}.trim() === ''}}", new_todo.val)) {
|
data-attr=(format!("{{disabled: {}.trim() === ''}}", new_todo.val)) {
|
||||||
"Add"
|
"Add"
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ fn todo_item(cx: &mut RenderContext, todo: &Todo) -> Markup {
|
|||||||
input
|
input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked[todo.completed]
|
checked[todo.completed]
|
||||||
data-on-click=(format!("@post('/action/toggle_todo?id={}')", todo.id))
|
data-on:click=(format!("@post('/action/toggle_todo?id={}')", todo.id))
|
||||||
;
|
;
|
||||||
|
|
||||||
// Title with strikethrough when completed (class set server-side)
|
// Title with strikethrough when completed (class set server-side)
|
||||||
@@ -114,7 +114,7 @@ fn todo_item(cx: &mut RenderContext, todo: &Todo) -> Markup {
|
|||||||
|
|
||||||
// Delete button
|
// Delete button
|
||||||
button.delete-btn
|
button.delete-btn
|
||||||
data-on-click=(format!("@post('/action/delete_todo?id={}')", todo.id))
|
data-on:click=(format!("@post('/action/delete_todo?id={}')", todo.id))
|
||||||
{
|
{
|
||||||
"×"
|
"×"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user