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
This commit is contained in:
2026-02-04 13:29:33 +01:00
parent 25e498aff0
commit 3774c3347c
5 changed files with 454 additions and 7 deletions

30
Cargo.lock generated
View File

@@ -2907,6 +2907,17 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.14.1" version = "0.14.1"
@@ -3542,6 +3553,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pipeline"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d15b6607fa632996eb8a17c9041cb6071cb75ac057abd45dece578723ea8c7c0"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
@@ -3905,6 +3922,18 @@ dependencies = [
"thiserror 2.0.18", "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]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@@ -5366,6 +5395,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"api", "api",
"dioxus", "dioxus",
"markdown",
"ui", "ui",
"uuid", "uuid",
] ]

View File

@@ -15,6 +15,15 @@ pub struct Note {
pub is_pinned: bool, 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<Utc>,
pub due_date: Option<DateTime<Utc>>,
}
impl Note { impl Note {
pub fn new(title: String, content: String, tags: Vec<String>) -> Self { pub fn new(title: String, content: String, tags: Vec<String>) -> Self {
let now = Utc::now(); let now = Utc::now();
@@ -30,7 +39,21 @@ impl Note {
} }
} }
impl Todo {
pub fn new(title: String, due_date: Option<DateTime<Utc>>) -> Self {
let now = Utc::now();
Self {
id: uuid::Uuid::new_v4().to_string(),
title,
completed: false,
created_at: now,
due_date,
}
}
}
type NotesStore = Mutex<Vec<Note>>; type NotesStore = Mutex<Vec<Note>>;
type TodosStore = Mutex<Vec<Todo>>;
static NOTES: LazyLock<NotesStore> = LazyLock::new(|| { static NOTES: LazyLock<NotesStore> = LazyLock::new(|| {
Mutex::new(vec![ Mutex::new(vec![
@@ -52,6 +75,14 @@ static NOTES: LazyLock<NotesStore> = LazyLock::new(|| {
]) ])
}); });
static TODOS: LazyLock<TodosStore> = 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")] #[post("/api/notes")]
pub async fn create_note(title: String, content: String, tags: Vec<String>) -> Result<String, ServerFnError> { pub async fn create_note(title: String, content: String, tags: Vec<String>) -> Result<String, ServerFnError> {
let mut notes = NOTES.lock().map_err(|e| ServerFnError::new(e.to_string()))?; 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<String, ServerFnError> { pub async fn echo(message: String) -> Result<String, ServerFnError> {
Ok(format!("Echo: {}", message)) Ok(format!("Echo: {}", message))
} }
// Todo endpoints
#[post("/api/todos")]
pub async fn create_todo(title: String, due_date: Option<String>) -> Result<String, ServerFnError> {
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<Vec<Todo>, 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()))
}
}

View File

@@ -8,6 +8,7 @@ dioxus = { workspace = true, features = ["router", "fullstack"] }
ui = { workspace = true } ui = { workspace = true }
api = { workspace = true } api = { workspace = true }
uuid = { workspace = true, features = ["js"] } uuid = { workspace = true, features = ["js"] }
markdown = "0.3.0"
[features] [features]
default = [] default = []

View File

@@ -449,6 +449,161 @@ body {
background: var(--text-muted); 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) { @media (max-width: 768px) {
.sidebar { .sidebar {
width: 100%; width: 100%;

View File

@@ -1,5 +1,5 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use api::Note; use api::{Note, Todo};
#[component] #[component]
pub fn BraindumpApp() -> Element { pub fn BraindumpApp() -> Element {
@@ -13,6 +13,10 @@ fn BraindumpLayout() -> Element {
let notes = use_resource(|| async move { let notes = use_resource(|| async move {
api::list_notes().await.unwrap_or_default() api::list_notes().await.unwrap_or_default()
}); });
let todos = use_resource(|| async move {
api::list_todos().await.unwrap_or_default()
});
let refresh_notes = { let refresh_notes = {
let mut notes_ref = notes.clone(); let mut notes_ref = notes.clone();
@@ -36,6 +40,9 @@ fn BraindumpLayout() -> Element {
search_query: search_query.clone(), search_query: search_query.clone(),
filter_tag: filter_tag.clone(), filter_tag: filter_tag.clone(),
} }
TodosPanel {
todos: todos.clone(),
}
SidebarContent { SidebarContent {
notes: notes.clone(), notes: notes.clone(),
selected_note_id: selected_note_id.clone(), selected_note_id: selected_note_id.clone(),
@@ -267,6 +274,7 @@ fn NoteEditor(
) -> Element { ) -> Element {
let mut note_id_signal = use_signal(|| note_id.clone()); 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 note = use_resource(move || {
let id = note_id_signal().clone(); let id = note_id_signal().clone();
async move { async move {
@@ -278,9 +286,22 @@ fn NoteEditor(
let mut content = use_signal(|| String::new()); let mut content = use_signal(|| String::new());
let mut tags = use_signal(|| String::new()); let mut tags = use_signal(|| String::new());
let mut is_pinned = use_signal(|| false); 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 || { 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 || { use_effect(move || {
@@ -293,8 +314,38 @@ fn NoteEditor(
} }
}); });
// Debounced auto-save function using counter-based debounce
let mut debounced_save = move |_: Event<FormData>| {
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 save_note = move |_: MouseEvent| {
let id = note_id_signal().clone(); let id = note_id_signal();
let title_val = title(); let title_val = title();
let content_val = content(); let content_val = content();
let tags_input = tags(); let tags_input = tags();
@@ -306,7 +357,9 @@ fn NoteEditor(
let pinned = is_pinned(); let pinned = is_pinned();
spawn(async move { spawn(async move {
saving_status.set(String::from("Saving..."));
let _ = api::update_note(id, title_val, content_val, tags_list, pinned).await; let _ = api::update_note(id, title_val, content_val, tags_list, pinned).await;
saving_status.set(String::from("Saved"));
on_save(()); on_save(());
}); });
}; };
@@ -354,22 +407,31 @@ fn NoteEditor(
class: "note-title-input", class: "note-title-input",
placeholder: "Note title...", placeholder: "Note title...",
value: "{title}", value: "{title}",
oninput: move |e| title.set(e.value()), oninput: move |e| {
title.set(e.value());
debounced_save(e);
},
} }
input { input {
class: "tags-input", class: "tags-input",
placeholder: "Tags (comma separated)...", placeholder: "Tags (comma separated)...",
value: "{tags}", value: "{tags}",
oninput: move |e| tags.set(e.value()), oninput: move |e| {
tags.set(e.value());
debounced_save(e);
},
} }
textarea { textarea {
class: "note-content-input", class: "note-content-input",
placeholder: "Write your note here...", placeholder: "Write your note here...",
value: "{content}", value: "{content}",
oninput: move |e| content.set(e.value()), oninput: move |e| {
content.set(e.value());
debounced_save(e);
},
} }
div { class: "editor-footer", div { class: "editor-footer",
span { class: "save-hint", "Auto-saving..." } span { class: "save-hint", "{saving_status}" }
button { button {
class: "save-button", class: "save-button",
onclick: save_note, onclick: save_note,
@@ -379,3 +441,133 @@ fn NoteEditor(
} }
} }
} }
#[component]
fn TodosPanel(todos: Resource<Vec<Todo>>) -> 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<FormData>| {
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<String>,
delete_todo: Callback<String>,
) -> 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()),
""
}
}
}
}