From 713c6bb08750aca526a9b101941e31fa3bb6ce40 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 10 May 2026 23:58:09 +0200 Subject: [PATCH] feat(examples): add browser kanban demo req: examples/001 req: form/002 req: list/003 req: push/003 --- Cargo.lock | 13 ++ Cargo.toml | 2 +- examples/kanban/Cargo.toml | 21 +++ examples/kanban/README.md | 18 ++ examples/kanban/build.rs | 3 + examples/kanban/src/lib.rs | 26 +++ examples/kanban/src/main.rs | 263 +++++++++++++++++++++++++++ examples/kanban/templates/board.heml | 18 ++ 8 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 examples/kanban/Cargo.toml create mode 100644 examples/kanban/README.md create mode 100644 examples/kanban/build.rs create mode 100644 examples/kanban/src/lib.rs create mode 100644 examples/kanban/src/main.rs create mode 100644 examples/kanban/templates/board.heml diff --git a/Cargo.lock b/Cargo.lock index e12df22..176a19f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -595,6 +595,19 @@ dependencies = [ name = "slhx-js" version = "0.1.0" +[[package]] +name = "slhx-kanban-example" +version = "0.1.0" +dependencies = [ + "axum", + "futures-util", + "slhx", + "slhx-axum", + "slhx-build", + "slhx-test", + "tokio", +] + [[package]] name = "slhx-test" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 584fb20..220c405 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "examples/v0"] +members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "examples/v0", "examples/kanban"] [workspace.package] version = "0.1.0" diff --git a/examples/kanban/Cargo.toml b/examples/kanban/Cargo.toml new file mode 100644 index 0000000..eb123b2 --- /dev/null +++ b/examples/kanban/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "slhx-kanban-example" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +axum = "0.7" +futures-util = "0.3" +slhx = { path = "../../slhx" } +slhx-axum = { path = "../../slhx-axum" } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } + +[dev-dependencies] +slhx-test = { path = "../../slhx-test" } + +[build-dependencies] +slhx-build = { path = "../../slhx-build" } diff --git a/examples/kanban/README.md b/examples/kanban/README.md new file mode 100644 index 0000000..f9557f2 --- /dev/null +++ b/examples/kanban/README.md @@ -0,0 +1,18 @@ +# slhx Kanban browser example + +Run: + + cargo run -p slhx-kanban-example + +Open . + +The example is a server-first Kanban board with: + +- add-card form +- move-left / move-right card controls +- delete-card controls +- generated handle ids and slots +- application/slhx EffectBatch responses +- SSE presence updates + +It intentionally uses buttons instead of custom JavaScript drag-and-drop; drag/local-first sync remain north-star features in `examples/kanban.md`. diff --git a/examples/kanban/build.rs b/examples/kanban/build.rs new file mode 100644 index 0000000..5f2e967 --- /dev/null +++ b/examples/kanban/build.rs @@ -0,0 +1,3 @@ +fn main() { + slhx_build::app().run().unwrap(); +} diff --git a/examples/kanban/src/lib.rs b/examples/kanban/src/lib.rs new file mode 100644 index 0000000..f5136af --- /dev/null +++ b/examples/kanban/src/lib.rs @@ -0,0 +1,26 @@ +#[slhx::surface] +pub mod ui {} + +#[cfg(test)] +mod tests { + use super::ui; + use slhx::{IntoEffect, SafeHtml}; + use slhx_test::inspect; + + // req: examples/001 req: codegen/002 req: list/003 + #[test] + fn kanban_board_updates_generated_slot() { + fn render_board() -> impl IntoEffect { + ui::board::slots::board.html(SafeHtml::trusted("
")) + } + + assert!(inspect(render_board()).has_slot(ui::board::slots::board)); + } + + // req: examples/001 req: form/002 req: codegen/003 + #[test] + fn kanban_template_exports_form_and_card_handles() { + assert_ne!(ui::board::handles::create_card.id().id, ui::board::handles::move_right.id().id); + assert_eq!(ui::board::forms::create_card.field("title").resource, ui::board::forms::create_card.id()); + } +} diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs new file mode 100644 index 0000000..da16db3 --- /dev/null +++ b/examples/kanban/src/main.rs @@ -0,0 +1,263 @@ +use axum::extract::{Query, State}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::Router; +use futures_util::{stream, StreamExt}; +use slhx::{IntoEffect, SafeHtml}; +use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest}; +use slhx_kanban_example::ui; +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"), ("done", "Done")]; + +#[derive(Default)] +struct AppState { + board: Mutex, +} + +#[derive(Default, Clone)] +struct Board { + next_id: u64, + cards: Vec, +} + +#[derive(Clone)] +struct Card { + id: u64, + title: String, + column: usize, +} + +#[tokio::main] +async fn main() { + let state = Arc::new(AppState { + board: Mutex::new(Board { + next_id: 4, + cards: vec![ + Card { id: 1, title: "Write requirements".into(), column: 0 }, + Card { id: 2, title: "Build browser example".into(), column: 1 }, + Card { id: 3, title: "Verify with HTTP".into(), column: 2 }, + ], + }), + }); + + let app = Router::new() + .route("/", get(home).post(interact)) + .route("/events", get(events)) + .route("/slhx.js", get(runtime)) + .with_state(state); + + let addr = SocketAddr::from(([127, 0, 0, 1], 3001)); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + println!("slhx Kanban example: http://{addr}"); + axum::serve(listener, app).await.unwrap(); +} + +// req: examples/001 req: component/003 +async fn home(State(state): State>, request: PageRequest) -> impl IntoResponse { + let board = state.board.lock().unwrap().clone(); + request + .page(page_html(&board), shell) + .title("slhx Kanban") + .fingerprint(ui::BUILD_FINGERPRINT) +} + +async fn runtime() -> impl IntoResponse { + runtime_js() +} + +async fn interact( + State(state): State>, + form: InteractionForm, +) -> Result { + registry(state).dispatch(form) +} + +// req: push/001 req: push/003 req: examples/001 +async fn events(Query(params): Query>) -> impl IntoResponse { + if params.contains_key("once") { + let effect = ui::board::slots::presence.html(SafeHtml::trusted(render_presence(1))); + return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed()); + } + + let batches = stream::unfold(1_u64, |count| async move { + tokio::time::sleep(Duration::from_secs(4)).await; + let effect = ui::board::slots::presence.html(SafeHtml::trusted(render_presence(count))); + Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1)) + }) + .boxed(); + sse(batches) +} + +fn registry(state: Arc) -> HandlerRegistry { + HandlerRegistry::new(ui::BUILD_FINGERPRINT) + .register(ui::board::handles::create_card.id().id, { + let state = state.clone(); + move |form| { + // req: examples/001 req: form/002 + let title = form.value("title").unwrap_or("").trim(); + let column = parse_column(form.value("column")); + let mut board = state.board.lock().unwrap(); + if !title.is_empty() { + let id = board.next_id; + board.next_id += 1; + board.cards.push(Card { id, title: title.into(), column }); + } + board_effects(&board, "Card added") + } + }) + .register(ui::board::handles::move_left.id().id, { + let state = state.clone(); + move |form| { + // req: examples/001 req: list/003 + let mut board = state.board.lock().unwrap(); + let moved = update_card(&mut board, form.value("card_id"), |card| { + card.column = card.column.saturating_sub(1); + }); + board_effects(&board, if moved { "Card moved left" } else { "Card not found" }) + } + }) + .register(ui::board::handles::move_right.id().id, { + let state = state.clone(); + move |form| { + // req: examples/001 req: list/003 + let mut board = state.board.lock().unwrap(); + let moved = update_card(&mut board, form.value("card_id"), |card| { + card.column = (card.column + 1).min(COLUMNS.len() - 1); + }); + board_effects(&board, if moved { "Card moved right" } else { "Card not found" }) + } + }) + .register(ui::board::handles::delete_card.id().id, { + let state = state.clone(); + move |form| { + // req: examples/001 req: list/003 + let mut board = state.board.lock().unwrap(); + let before = board.cards.len(); + if let Some(id) = form.value("card_id").and_then(|id| id.parse::().ok()) { + board.cards.retain(|card| card.id != id); + } + board_effects(&board, if board.cards.len() < before { "Card deleted" } else { "Card not found" }) + } + }) +} + +fn board_effects(board: &Board, notice: &'static str) -> impl IntoEffect { + ( + ui::board::slots::board.html(SafeHtml::trusted(render_board(board))), + ui::board::slots::notice.text(notice), + ui::board::forms::create_card.clear("title"), + ) +} + +fn update_card(board: &mut Board, card_id: Option<&str>, update: impl FnOnce(&mut Card)) -> bool { + let Some(id) = card_id.and_then(|id| id.parse::().ok()) else { + return false; + }; + let Some(card) = board.cards.iter_mut().find(|card| card.id == id) else { + return false; + }; + update(card); + true +} + +fn parse_column(value: Option<&str>) -> usize { + let id = value.unwrap_or(COLUMNS[0].0); + COLUMNS + .iter() + .position(|(column_id, _)| *column_id == id) + .unwrap_or(0) +} + +fn page_html(board: &Board) -> String { + ui::board::lower_html(include_str!("../templates/board.heml")) + .replace("__OPTIONS__", &render_options()) + .replace("__BOARD__", &render_board(board)) +} + +fn shell(body: String) -> String { + format!( + r#" + + + + + slhx Kanban + + + + + {body} + +"# + ) +} + +fn render_options() -> String { + COLUMNS + .iter() + .map(|(id, title)| format!(r#""#, escape_html(id), escape_html(title))) + .collect::>() + .join("") +} + +fn render_board(board: &Board) -> String { + let mut out = String::from("
"); + for (idx, (_, title)) in COLUMNS.iter().enumerate() { + out.push_str(&format!(r#"

{}

"#, escape_html(title))); + for card in board.cards.iter().filter(|card| card.column == idx) { + out.push_str(&render_card(card)); + } + out.push_str("
"); + } + out.push_str("
"); + out +} + +fn render_card(card: &Card) -> String { + format!( + r#"
+ {title} + + + + + +
"#, + id = card.id, + title = escape_html(&card.title), + left = ui::board::handles::move_left.id().id, + right = ui::board::handles::move_right.id().id, + delete = ui::board::handles::delete_card.id().id, + left_disabled = if card.column == 0 { "disabled" } else { "" }, + right_disabled = if card.column + 1 == COLUMNS.len() { "disabled" } else { "" }, + ) +} + +fn render_presence(count: u64) -> String { + format!( + r#"Ada online Grace online tick #{count}"# + ) +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/examples/kanban/templates/board.heml b/examples/kanban/templates/board.heml new file mode 100644 index 0000000..f46ee36 --- /dev/null +++ b/examples/kanban/templates/board.heml @@ -0,0 +1,18 @@ +
+
+

slhx Kanban

+
+ + + +
+

Ready

+
+ +
__BOARD__
+ + + + + +