124 lines
4.2 KiB
Rust
124 lines
4.2 KiB
Rust
use crate::queries::{ListTodos, Todo};
|
||
use livevue_rs::{RenderContext, global, mangle};
|
||
use maud::{html, Markup};
|
||
|
||
/// Root page component: renders the full todo list with add form.
|
||
///
|
||
/// Reads signals:
|
||
/// - `newTodo` (global, server-relevant): the text input value
|
||
///
|
||
/// Exercises:
|
||
/// - `cx.run()` for data fetching
|
||
/// - `cx.signal()` with `global()` scoping + `Signal::val` in expressions
|
||
/// - `cx.signal()` with `mangle().client_only()` scoping (in todo_item)
|
||
/// - DataStar `data-bind`, `data-on:click`, `data-attr` attributes
|
||
/// - `@post()` actions for mutations
|
||
pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||
// Fetch all todos
|
||
let todos = cx.run(ListTodos).await?;
|
||
|
||
// Global signal for the "new todo" input. This call does two things:
|
||
// 1. Records "newTodo" as a touched signal (for demangling tracking)
|
||
// 2. Returns a Signal struct whose .val is "$newTodo" for expressions
|
||
let new_todo = cx.signal("newTodo", global());
|
||
|
||
let todo_count = todos.len();
|
||
let done_count = todos.iter().filter(|t| t.completed).count();
|
||
|
||
Ok(html! {
|
||
div.todo-app {
|
||
h1 { "LiveVue.rs — Todo List" }
|
||
|
||
// Navigation link to About page (exercises tree signal + page routing)
|
||
nav {
|
||
span.nav-current { "Todos" }
|
||
" | "
|
||
button.nav-link
|
||
data-on:click="$tree = {page: 'About'};" {
|
||
"About"
|
||
}
|
||
}
|
||
|
||
// Add todo form.
|
||
// data-bind="newTodo" binds the input to the $newTodo signal.
|
||
// Value syntax is used instead of data-bind-newTodo because HTML
|
||
// normalises attribute names to lowercase, which would corrupt the
|
||
// camelCase signal name (newTodo → newtodo).
|
||
div.add-form {
|
||
input
|
||
type="text"
|
||
placeholder="What needs to be done?"
|
||
data-bind="newTodo"
|
||
;
|
||
button
|
||
data-on:click="@post('/action/add_todo')"
|
||
data-attr=(format!("{{disabled: {}.trim() === ''}}", new_todo.val)) {
|
||
"Add"
|
||
}
|
||
}
|
||
|
||
// Stats
|
||
p.stats {
|
||
(format!("{} items, {} completed", todo_count, done_count))
|
||
}
|
||
|
||
// Todo items
|
||
ul.todo-list {
|
||
@for todo in &todos {
|
||
(todo_item(cx, todo))
|
||
}
|
||
}
|
||
|
||
@if todos.is_empty() {
|
||
p.empty { "No todos yet. Add one above!" }
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Renders a single todo item.
|
||
///
|
||
/// This is a static child component — called directly from `todo_list`,
|
||
/// not resolved through the component registry.
|
||
///
|
||
/// Demonstrates per-instance, client-only signal via `mangle().client_only()`.
|
||
fn todo_item(cx: &mut RenderContext, todo: &Todo) -> Markup {
|
||
// Per-instance, client-only signal for edit mode.
|
||
// Not used in v0 rendering, but demonstrates the mangled signal API
|
||
// and ensures it's tracked as touched. Produces: "_editing__<id>"
|
||
let _editing = cx.signal("editing", mangle(todo.id).client_only());
|
||
|
||
// Build class string server-side. Since we do a full rerender on every
|
||
// state change, there's no need for data-class here — the server knows
|
||
// whether the todo is completed.
|
||
let title_class = if todo.completed {
|
||
"todo-title completed"
|
||
} else {
|
||
"todo-title"
|
||
};
|
||
|
||
html! {
|
||
li.todo-item id=(format!("todo-{}", todo.id)) {
|
||
// Checkbox to toggle completion.
|
||
// `checked[expr]` is maud's syntax for conditional boolean attributes.
|
||
input
|
||
type="checkbox"
|
||
checked[todo.completed]
|
||
data-on:click=(format!("@post('/action/toggle_todo?id={}')", todo.id))
|
||
;
|
||
|
||
// Title with strikethrough when completed (class set server-side)
|
||
span class=(title_class) {
|
||
(todo.title)
|
||
}
|
||
|
||
// Delete button
|
||
button.delete-btn
|
||
data-on:click=(format!("@post('/action/delete_todo?id={}')", todo.id))
|
||
{
|
||
"×"
|
||
}
|
||
}
|
||
}
|
||
}
|