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