From 3774c3347cc638f2dfec51a1b89cabf03809887d Mon Sep 17 00:00:00 2001 From: Labby Date: Wed, 4 Feb 2026 13:29:33 +0100 Subject: [PATCH] feat: add auto-save debounce, note refetch on switch, todos feature with markdown Features implemented: - Auto-save with counter-based debounce in note editor - Note refetches when switching between notes (note_id prop changes) - Todos feature with full CRUD operations - Markdown rendering in todo titles - API endpoints for todos (create, list, toggle, delete) - Todo panel in sidebar with sorting (incomplete first, newest first) - Todo items use TodoItem component for clean separation Technical changes: - Added Todo struct and API endpoints in api package - Added markdown dependency to web package - Implemented TodoPanel component with TodoItem sub-component - Added mut keywords to signal bindings for Dioxus 0.7 - Fixed closure capture issues with cloning todo objects --- Cargo.lock | 30 +++++ packages/api/src/lib.rs | 69 ++++++++++ packages/web/Cargo.toml | 1 + packages/web/assets/braindump.css | 155 ++++++++++++++++++++++ packages/web/src/braindump.rs | 206 +++++++++++++++++++++++++++++- 5 files changed, 454 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a3ebe5..bf5ebf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2907,6 +2907,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "markdown" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef3aab6a1d529b112695f72beec5ee80e729cb45af58663ec902c8fac764ecdd" +dependencies = [ + "lazy_static", + "pipeline", + "regex", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -3542,6 +3553,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pipeline" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15b6607fa632996eb8a17c9041cb6071cb75ac057abd45dece578723ea8c7c0" + [[package]] name = "pkg-config" version = "0.3.32" @@ -3905,6 +3922,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -5366,6 +5395,7 @@ version = "0.1.0" dependencies = [ "api", "dioxus", + "markdown", "ui", "uuid", ] diff --git a/packages/api/src/lib.rs b/packages/api/src/lib.rs index 402c1be..6fffc75 100644 --- a/packages/api/src/lib.rs +++ b/packages/api/src/lib.rs @@ -15,6 +15,15 @@ pub struct Note { pub is_pinned: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Todo { + pub id: String, + pub title: String, + pub completed: bool, + pub created_at: DateTime, + pub due_date: Option>, +} + impl Note { pub fn new(title: String, content: String, tags: Vec) -> Self { let now = Utc::now(); @@ -30,7 +39,21 @@ impl Note { } } +impl Todo { + pub fn new(title: String, due_date: Option>) -> Self { + let now = Utc::now(); + Self { + id: uuid::Uuid::new_v4().to_string(), + title, + completed: false, + created_at: now, + due_date, + } + } +} + type NotesStore = Mutex>; +type TodosStore = Mutex>; static NOTES: LazyLock = LazyLock::new(|| { Mutex::new(vec![ @@ -52,6 +75,14 @@ static NOTES: LazyLock = LazyLock::new(|| { ]) }); +static TODOS: LazyLock = LazyLock::new(|| { + Mutex::new(vec![ + Todo::new("Review project proposal".to_string(), Some(Utc::now() + chrono::Duration::days(2))), + Todo::new("Complete documentation".to_string(), Some(Utc::now() + chrono::Duration::days(5))), + Todo::new("Team meeting preparation".to_string(), Some(Utc::now() + chrono::Duration::days(1))), + ]) +}); + #[post("/api/notes")] pub async fn create_note(title: String, content: String, tags: Vec) -> Result { let mut notes = NOTES.lock().map_err(|e| ServerFnError::new(e.to_string()))?; @@ -118,3 +149,41 @@ pub async fn toggle_pin_note(id: String) -> Result<(), ServerFnError> { pub async fn echo(message: String) -> Result { Ok(format!("Echo: {}", message)) } + +// Todo endpoints +#[post("/api/todos")] +pub async fn create_todo(title: String, due_date: Option) -> Result { + let mut todos = TODOS.lock().map_err(|e| ServerFnError::new(e.to_string()))?; + let due_date_parsed = due_date.and_then(|d| chrono::DateTime::parse_from_rfc3339(&d).ok()).map(|d| d.with_timezone(&Utc)); + let todo = Todo::new(title, due_date_parsed); + todos.push(todo.clone()); + Ok(todo.id) +} + +#[get("/api/todos")] +pub async fn list_todos() -> Result, ServerFnError> { + let todos = TODOS.lock().map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(todos.clone()) +} + +#[post("/api/todos/:id/toggle")] +pub async fn toggle_todo(id: String) -> Result<(), ServerFnError> { + let mut todos = TODOS.lock().map_err(|e| ServerFnError::new(e.to_string()))?; + if let Some(todo) = todos.iter_mut().find(|t| t.id == id) { + todo.completed = !todo.completed; + Ok(()) + } else { + Err(ServerFnError::new("Todo not found".to_string())) + } +} + +#[post("/api/todos/:id/delete")] +pub async fn delete_todo(id: String) -> Result<(), ServerFnError> { + let mut todos = TODOS.lock().map_err(|e| ServerFnError::new(e.to_string()))?; + if todos.iter().any(|t| t.id == id) { + todos.retain(|t| t.id != id); + Ok(()) + } else { + Err(ServerFnError::new("Todo not found".to_string())) + } +} diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml index 11589bb..96902e0 100644 --- a/packages/web/Cargo.toml +++ b/packages/web/Cargo.toml @@ -8,6 +8,7 @@ dioxus = { workspace = true, features = ["router", "fullstack"] } ui = { workspace = true } api = { workspace = true } uuid = { workspace = true, features = ["js"] } +markdown = "0.3.0" [features] default = [] diff --git a/packages/web/assets/braindump.css b/packages/web/assets/braindump.css index 995fd45..7e008f2 100644 --- a/packages/web/assets/braindump.css +++ b/packages/web/assets/braindump.css @@ -449,6 +449,161 @@ body { background: var(--text-muted); } +.todos-section { + padding: 1rem 1.5rem 0.5rem; + border-bottom: 1px solid var(--border); + background: var(--bg-tertiary); +} + +.todos-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.todos-title::before { + content: "📋"; + font-size: 1.2rem; +} + +.todo-form { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.todo-input { + flex: 1; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.85rem; + outline: none; + transition: all 0.2s; +} + +.todo-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2); +} + +.todo-input::placeholder { + color: var(--text-muted); +} + +.todo-add-button { + padding: 0.6rem 1rem; + background: var(--accent); + color: white; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.todo-add-button:hover { + background: var(--accent-hover); +} + +.todos-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.todo-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.8rem; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + transition: all 0.2s; + cursor: pointer; +} + +.todo-item:hover { + border-color: var(--accent); + background: var(--bg-hover); +} + +.todo-item.completed { + opacity: 0.6; +} + +.todo-item.completed .todo-title { + text-decoration: line-through; + color: var(--text-muted); +} + +.todo-checkbox { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--accent); +} + +.todo-title { + flex: 1; + font-size: 0.9rem; + color: var(--text-primary); + line-height: 1.4; +} + +.todo-title strong { + font-weight: 700; +} + +.todo-title em { + font-style: italic; +} + +.todo-title code { + background: var(--bg-tertiary); + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.85rem; +} + +.todo-title a { + color: var(--accent); + text-decoration: none; +} + +.todo-title a:hover { + text-decoration: underline; +} + +.todo-delete-button { + width: 24px; + height: 24px; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; + font-size: 1rem; +} + +.todo-delete-button:hover { + background: var(--danger); + color: white; +} + @media (max-width: 768px) { .sidebar { width: 100%; diff --git a/packages/web/src/braindump.rs b/packages/web/src/braindump.rs index 0726f9f..733d0f4 100644 --- a/packages/web/src/braindump.rs +++ b/packages/web/src/braindump.rs @@ -1,5 +1,5 @@ use dioxus::prelude::*; -use api::Note; +use api::{Note, Todo}; #[component] pub fn BraindumpApp() -> Element { @@ -13,6 +13,10 @@ fn BraindumpLayout() -> Element { let notes = use_resource(|| async move { api::list_notes().await.unwrap_or_default() }); + + let todos = use_resource(|| async move { + api::list_todos().await.unwrap_or_default() + }); let refresh_notes = { let mut notes_ref = notes.clone(); @@ -36,6 +40,9 @@ fn BraindumpLayout() -> Element { search_query: search_query.clone(), filter_tag: filter_tag.clone(), } + TodosPanel { + todos: todos.clone(), + } SidebarContent { notes: notes.clone(), selected_note_id: selected_note_id.clone(), @@ -267,6 +274,7 @@ fn NoteEditor( ) -> Element { let mut note_id_signal = use_signal(|| note_id.clone()); + // Fetch note on mount and refetch when note_id changes let note = use_resource(move || { let id = note_id_signal().clone(); async move { @@ -278,9 +286,22 @@ fn NoteEditor( let mut content = use_signal(|| String::new()); let mut tags = use_signal(|| String::new()); let mut is_pinned = use_signal(|| false); + let mut saving_status = use_signal(|| String::from("Saved")); + let mut debounce_key = use_signal(|| 0u64); + // Refetch note when note_id prop changes and clear fields to show loading state + let mut note_id_effect_id = use_signal(|| 0u64); use_effect(move || { - note_id_signal.set(note_id.clone()); + let current_id = note_id.clone(); + let effect_id = note_id_effect_id().wrapping_add(1); + note_id_effect_id.set(effect_id); + + note_id_signal.set(current_id.clone()); + // Clear fields while loading new note + title.set(String::new()); + content.set(String::new()); + tags.set(String::new()); + is_pinned.set(false); }); use_effect(move || { @@ -293,8 +314,38 @@ fn NoteEditor( } }); + // Debounced auto-save function using counter-based debounce + let mut debounced_save = move |_: Event| { + let save_id = debounce_key().wrapping_add(1); + debounce_key.set(save_id); + + let id = note_id_signal(); + let title_val = title(); + let content_val = content(); + let tags_input = tags(); + let tags_list = tags_input + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + let pinned = is_pinned(); + let on_save = on_save.clone(); + let debounce_key_clone = debounce_key.clone(); + let mut saving_status = saving_status.clone(); + + spawn(async move { + // Debounce by checking if this save is still the latest + if debounce_key_clone() == save_id { + saving_status.set(String::from("Saving...")); + let _ = api::update_note(id, title_val, content_val, tags_list, pinned).await; + saving_status.set(String::from("Saved")); + on_save(()); + } + }); + }; + let save_note = move |_: MouseEvent| { - let id = note_id_signal().clone(); + let id = note_id_signal(); let title_val = title(); let content_val = content(); let tags_input = tags(); @@ -306,7 +357,9 @@ fn NoteEditor( let pinned = is_pinned(); spawn(async move { + saving_status.set(String::from("Saving...")); let _ = api::update_note(id, title_val, content_val, tags_list, pinned).await; + saving_status.set(String::from("Saved")); on_save(()); }); }; @@ -354,22 +407,31 @@ fn NoteEditor( class: "note-title-input", placeholder: "Note title...", value: "{title}", - oninput: move |e| title.set(e.value()), + oninput: move |e| { + title.set(e.value()); + debounced_save(e); + }, } input { class: "tags-input", placeholder: "Tags (comma separated)...", value: "{tags}", - oninput: move |e| tags.set(e.value()), + oninput: move |e| { + tags.set(e.value()); + debounced_save(e); + }, } textarea { class: "note-content-input", placeholder: "Write your note here...", value: "{content}", - oninput: move |e| content.set(e.value()), + oninput: move |e| { + content.set(e.value()); + debounced_save(e); + }, } div { class: "editor-footer", - span { class: "save-hint", "Auto-saving..." } + span { class: "save-hint", "{saving_status}" } button { class: "save-button", onclick: save_note, @@ -379,3 +441,133 @@ fn NoteEditor( } } } + +#[component] +fn TodosPanel(todos: Resource>) -> Element { + let mut new_todo = use_signal(|| String::new()); + + let add_todo = { + let new_todo = new_todo.clone(); + let todos_ref = todos.clone(); + move |e: Event| { + e.prevent_default(); + let title = new_todo().clone(); + if title.trim().is_empty() { + return; + } + let mut todos_ref = todos_ref.clone(); + let mut new_todo = new_todo.clone(); + spawn(async move { + if let Ok(_) = api::create_todo(title, None).await { + new_todo.set(String::new()); + if let Ok(new_todos) = api::list_todos().await { + todos_ref.set(Some(new_todos)); + } + } + }); + } + }; + + let toggle_todo = { + let todos_ref = todos.clone(); + move |id: String| { + let mut todos_ref = todos_ref.clone(); + spawn(async move { + if let Ok(_) = api::toggle_todo(id.clone()).await { + if let Ok(new_todos) = api::list_todos().await { + todos_ref.set(Some(new_todos)); + } + } + }); + } + }; + + let delete_todo = { + let todos_ref = todos.clone(); + move |id: String| { + let mut todos_ref = todos_ref.clone(); + spawn(async move { + if let Ok(_) = api::delete_todo(id.clone()).await { + if let Ok(new_todos) = api::list_todos().await { + todos_ref.set(Some(new_todos)); + } + } + }); + } + }; + + let todo_items = use_memo(move || { + let todos = match &*todos.read_unchecked() { + Some(t) => t.clone(), + None => vec![], + }; + let mut sorted = todos; + sorted.sort_by(|a, b| { + if a.completed == b.completed { + b.created_at.cmp(&a.created_at) + } else { + a.completed.cmp(&b.completed) + } + }); + sorted + }); + + rsx! { + div { class: "todos-section", + h2 { class: "todos-title", "Todos" } + form { + class: "todo-form", + onsubmit: add_todo, + input { + class: "todo-input", + placeholder: "Add new todo...", + value: "{new_todo}", + oninput: move |e| new_todo.set(e.value()), + } + button { + class: "todo-add-button", + "Add" + } + } + div { class: "todos-list", + for todo in todo_items() { + TodoItem { + todo: todo.clone(), + toggle_todo: toggle_todo.clone(), + delete_todo: delete_todo.clone(), + } + } + } + } + } +} + +#[component] +fn TodoItem( + todo: Todo, + toggle_todo: Callback, + delete_todo: Callback, +) -> Element { + let todo1 = todo.clone(); + let todo2 = todo.clone(); + rsx! { + div { + class: if todo1.completed { "todo-item completed" } else { "todo-item" }, + input { + class: "todo-checkbox", + r#type: "checkbox", + checked: todo1.completed, + onchange: move |_| toggle_todo(todo1.id.clone()), + } + div { + class: "todo-title", + dangerous_inner_html: "{markdown::to_html(&todo1.title)}", + } + button { + class: "todo-delete-button", + onclick: move |_| delete_todo(todo2.id.clone()), + "✕" + } + } + } +}