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

View File

@@ -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<Utc>,
pub due_date: Option<DateTime<Utc>>,
}
impl Note {
pub fn new(title: String, content: String, tags: Vec<String>) -> Self {
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 TodosStore = Mutex<Vec<Todo>>;
static NOTES: LazyLock<NotesStore> = LazyLock::new(|| {
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")]
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()))?;
@@ -118,3 +149,41 @@ pub async fn toggle_pin_note(id: String) -> Result<(), ServerFnError> {
pub async fn echo(message: String) -> Result<String, ServerFnError> {
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 }
api = { workspace = true }
uuid = { workspace = true, features = ["js"] }
markdown = "0.3.0"
[features]
default = []

View File

@@ -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%;

View File

@@ -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<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 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<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()),
""
}
}
}
}