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('\'', "'") }