rust todo web app
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
*#*
|
||||
*~
|
||||
Generated
+1456
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "rust-todo"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
maud = "0.27.0"
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
This program runs a web server for a todo web app.
|
||||
|
||||
NOTE: this is a recreational project.
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
use std::env;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[macro_use] extern crate rocket;
|
||||
use rocket::State;
|
||||
use rocket::form::Form;
|
||||
use rocket::response::content::RawHtml;
|
||||
use maud::{html, DOCTYPE};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Todo {
|
||||
id: u32,
|
||||
title: String,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
/// In-memory store for todos, protected by a Mutex for thread-safe access.
|
||||
/// Rocket handlers access it via `&State<TodoStore>`.
|
||||
struct TodoStore {
|
||||
todos: Mutex<Vec<Todo>>,
|
||||
next_id: Mutex<u32>,
|
||||
}
|
||||
|
||||
impl TodoStore {
|
||||
fn new() -> Self {
|
||||
TodoStore {
|
||||
todos: Mutex::new(Vec::new()),
|
||||
next_id: Mutex::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a new todo and returns a clone of it (so the handler can render it).
|
||||
fn add(&self, title: String) -> Todo {
|
||||
let mut todos = self.todos.lock().unwrap();
|
||||
let mut next_id = self.next_id.lock().unwrap();
|
||||
let todo = Todo {
|
||||
id: *next_id,
|
||||
title,
|
||||
completed: false,
|
||||
};
|
||||
*next_id += 1;
|
||||
todos.push(Todo {
|
||||
id: todo.id,
|
||||
title: todo.title.clone(),
|
||||
completed: todo.completed,
|
||||
});
|
||||
todo
|
||||
}
|
||||
|
||||
/// Toggles the completed status of a todo by ID. Returns the updated todo if found.
|
||||
fn toggle(&self, id: u32) -> Option<Todo> {
|
||||
let mut todos = self.todos.lock().unwrap();
|
||||
todos.iter_mut().find(|t| t.id == id).map(|todo| {
|
||||
todo.completed = !todo.completed;
|
||||
Todo {
|
||||
id: todo.id,
|
||||
title: todo.title.clone(),
|
||||
completed: todo.completed,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Deletes a todo by ID. Returns true if something was removed.
|
||||
fn delete(&self, id: u32) -> bool {
|
||||
let mut todos = self.todos.lock().unwrap();
|
||||
let len_before = todos.len();
|
||||
todos.retain(|t| t.id != id);
|
||||
todos.len() < len_before
|
||||
}
|
||||
|
||||
/// Returns a snapshot (cloned Vec) of all todos for rendering.
|
||||
fn list(&self) -> Vec<Todo> {
|
||||
self.todos.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a slice of todos and generates the full HTML String response,
|
||||
/// including a new-todo input form and HTMX triggers for toggling/deleting.
|
||||
fn render_todo_page(todos: &[Todo]) -> String {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" {
|
||||
head {
|
||||
meta charset="UTF-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
title { "Rust + HTMX + Tailwind Todos" }
|
||||
script src="https://cdn.tailwindcss.com" {}
|
||||
script src="https://unpkg.com/[email protected]" {}
|
||||
}
|
||||
body class="bg-slate-50 text-slate-800 min-h-screen flex items-center justify-center" {
|
||||
div class="max-w-md w-full bg-white rounded-xl shadow-md p-6 m-4" {
|
||||
h1 class="text-2xl font-bold text-slate-900 mb-6 text-center" { "My Todo List" }
|
||||
|
||||
// 1. Input Form to create a new todo
|
||||
form
|
||||
hx-post="/todos"
|
||||
hx-target="#todo-list"
|
||||
hx-swap="beforeend"
|
||||
class="flex gap-2 mb-6"
|
||||
{
|
||||
input
|
||||
type="text"
|
||||
name="title"
|
||||
placeholder="Add a new todo..."
|
||||
required
|
||||
class="flex-1 px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm";
|
||||
button
|
||||
type="submit"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
{
|
||||
"Add"
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Todo List Container
|
||||
div id="todo-list" class="space-y-3" {
|
||||
@for todo in todos {
|
||||
(render_todo_item(todo))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.into_string()
|
||||
}
|
||||
|
||||
/// Renders an individual todo item (useful to reuse when returning a single updated item via HTMX)
|
||||
fn render_todo_item(todo: &Todo) -> maud::Markup {
|
||||
let text_style = if todo.completed {
|
||||
"line-through text-slate-400"
|
||||
} else {
|
||||
"text-slate-700"
|
||||
};
|
||||
|
||||
html! {
|
||||
div class="flex items-center justify-between p-3 bg-slate-100 rounded-lg border border-slate-200 todo" {
|
||||
div class="flex items-center space-x-3" {
|
||||
input
|
||||
type="checkbox"
|
||||
checked[todo.completed]
|
||||
// HTMX attribute to toggle completion on the server
|
||||
hx-patch=(format!("/todos/{}/toggle", todo.id))
|
||||
hx-target="closest div.todo"
|
||||
hx-swap="outerHTML"
|
||||
class="w-4 h-4 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer";
|
||||
span class=(text_style) {
|
||||
(todo.title)
|
||||
}
|
||||
}
|
||||
button
|
||||
class="text-red-500 hover:text-red-700 text-sm font-medium transition-colors"
|
||||
hx-delete=(format!("/todos/{}", todo.id))
|
||||
hx-target="closest div.flex"
|
||||
hx-swap="outerHTML"
|
||||
{
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
fn index(store: &State<TodoStore>) -> RawHtml<String> {
|
||||
let todos = store.list();
|
||||
RawHtml(render_todo_page(&todos))
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
struct CreateRequest {
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[post("/todos", data = "<request>")]
|
||||
fn create_todo(request: Form<CreateRequest>, store: &State<TodoStore>) -> RawHtml<String> {
|
||||
let todo = store.add(request.title.clone());
|
||||
RawHtml(render_todo_item(&todo).into_string())
|
||||
}
|
||||
|
||||
#[patch("/todos/<id>/toggle")]
|
||||
fn toggle_todo(id: u32, store: &State<TodoStore>) -> RawHtml<String> {
|
||||
match store.toggle(id) {
|
||||
Some(todo) => RawHtml(render_todo_item(&todo).into_string()),
|
||||
None => RawHtml(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/todos/<id>")]
|
||||
fn delete_todo(id: u32, store: &State<TodoStore>) -> RawHtml<String> {
|
||||
store.delete(id);
|
||||
RawHtml(String::new())
|
||||
}
|
||||
|
||||
#[launch]
|
||||
fn rocket() -> _ {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
let program = &args[0];
|
||||
|
||||
println!("Program: {}", program);
|
||||
|
||||
if let Some(command) = args.get(1) {
|
||||
println!("Command: {}", command);
|
||||
match command.as_str() {
|
||||
"add" => {
|
||||
println!("command");
|
||||
// parse -m flag value
|
||||
},
|
||||
_ => todo!()
|
||||
}
|
||||
}
|
||||
|
||||
rocket::build()
|
||||
.manage(TodoStore::new())
|
||||
.mount("/", routes![index, create_todo, delete_todo, toggle_todo])
|
||||
}
|
||||
Reference in New Issue
Block a user