From 9dbd258c8b73bf35ce3362b57c9708cd05d01887 Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 11 Jan 2026 17:17:39 -0500 Subject: [PATCH] [weaver-app] enable New Notebook button with creation dialog --- .../assets/styling/notebook-editor.css | 138 ++++---- .../src/components/profile_actions.rs | 324 +++++++++++++++++- 2 files changed, 388 insertions(+), 74 deletions(-) diff --git a/crates/weaver-app/assets/styling/notebook-editor.css b/crates/weaver-app/assets/styling/notebook-editor.css index 2e47942..e2258c5 100644 --- a/crates/weaver-app/assets/styling/notebook-editor.css +++ b/crates/weaver-app/assets/styling/notebook-editor.css @@ -1,126 +1,122 @@ /* Notebook editor form component */ .notebook-editor { - display: flex; - flex-direction: column; - gap: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; } .notebook-editor-title { - margin: 0; - font-size: 1.25rem; - font-weight: 600; + margin: 0; + font-size: 1.25rem; + font-weight: 600; } .notebook-editor-form { - display: flex; - flex-direction: column; - gap: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; } .notebook-editor-field { - display: flex; - flex-direction: column; - gap: 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; } .notebook-editor-field label { - font-size: 0.875rem; - font-weight: 500; + font-size: 0.875rem; + font-weight: 500; } .notebook-editor-field input[type="text"] { - padding: 0.5rem; - border: 1px solid var(--color-border); - border-radius: 4px; - background: var(--color-base); - color: var(--color-text); - font-size: 0.875rem; + padding: 0.5rem; + border: 1px solid var(--color-border); + background: var(--color-base); + color: var(--color-text); + font-size: 0.875rem; } .notebook-editor-field input[type="text"]:focus { - outline: 2px solid var(--color-primary); - outline-offset: -1px; + outline: 2px solid var(--color-primary); + outline-offset: -1px; } .notebook-editor-hint { - font-size: 0.75rem; - color: var(--color-text-muted); + font-size: 0.75rem; + color: var(--color-muted); } .notebook-editor-toggle { - flex-direction: row; - align-items: flex-start; - gap: 0.5rem; + flex-direction: row; + align-items: flex-start; + gap: 0.5rem; } .notebook-editor-toggle label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; } .notebook-editor-tags { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - padding: 0.5rem; - border: 1px solid var(--color-border); - border-radius: 4px; - background: var(--color-base); + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem; + border: 1px solid var(--color-border); + background: var(--color-base); } .notebook-editor-tag { - display: flex; - align-items: center; - gap: 0.25rem; - padding: 0.25rem 0.5rem; - background: var(--color-surface); - border-radius: 4px; - font-size: 0.75rem; + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: var(--color-surface); + font-size: 0.75rem; } .notebook-editor-tag-remove { - background: none; - border: none; - color: var(--color-text-muted); - cursor: pointer; - padding: 0; - font-size: 1rem; - line-height: 1; + background: none; + border: none; + color: var(--color-muted); + cursor: pointer; + padding: 0; + font-size: 1rem; + line-height: 1; } .notebook-editor-tag-remove:hover { - color: var(--color-error); + color: var(--color-error); } .notebook-editor-tags-input { - flex: 1; - min-width: 8rem; - border: none; - background: transparent; - font-size: 0.875rem; - color: var(--color-text); + flex: 1; + min-width: 8rem; + border: none; + background: transparent; + font-size: 0.875rem; + color: var(--color-text); } .notebook-editor-tags-input:focus { - outline: none; + outline: none; } .notebook-editor-error { - padding: 0.75rem; - background: var(--color-error-bg, rgba(235, 111, 146, 0.1)); - border: 1px solid var(--color-error); - border-radius: 4px; - color: var(--color-error); - font-size: 0.875rem; + padding: 0.75rem; + background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); + border: 1px solid var(--color-error); + color: var(--color-error); + font-size: 0.875rem; } .notebook-editor-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; - padding-top: 0.5rem; - border-top: 1px solid var(--color-border); + display: flex; + gap: 0.5rem; + justify-content: flex-end; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border); } diff --git a/crates/weaver-app/src/components/profile_actions.rs b/crates/weaver-app/src/components/profile_actions.rs index 7e4aa60..db5899b 100644 --- a/crates/weaver-app/src/components/profile_actions.rs +++ b/crates/weaver-app/src/components/profile_actions.rs @@ -3,8 +3,15 @@ use crate::auth::AuthState; use crate::components::app_link::{AppLink, AppLinkTarget}; use crate::components::button::{Button, ButtonVariant}; +use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; +use crate::components::notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState}; +use crate::fetch::Fetcher; +use crate::Route; use dioxus::prelude::*; use jacquard::types::ident::AtIdentifier; +use weaver_api::com_atproto::repo::create_record::CreateRecord; +use weaver_api::sh_weaver::actor::Author; +use weaver_api::sh_weaver::notebook::book::Book; const PROFILE_ACTIONS_CSS: Asset = asset!("/assets/styling/profile-actions.css"); @@ -12,8 +19,15 @@ const PROFILE_ACTIONS_CSS: Asset = asset!("/assets/styling/profile-actions.css") #[component] pub fn ProfileActions(ident: ReadSignal>) -> Element { let auth_state = use_context::>(); + let fetcher = use_context::(); + let navigator = use_navigator(); - // Check if viewing own profile + // State for the create notebook dialog. + let mut show_create_dialog = use_signal(|| false); + let mut saving = use_signal(|| false); + let mut error = use_signal(|| None::); + + // Check if viewing own profile. let is_owner = { let current_did = auth_state.read().did.clone(); match (¤t_did, ident()) { @@ -26,6 +40,122 @@ pub fn ProfileActions(ident: ReadSignal>) -> Element { return rsx! {}; } + // Handler for saving a new notebook. + let create_fetcher = fetcher.clone(); + let handle_save = move |form_state: NotebookFormState| { + let fetcher = create_fetcher.clone(); + let navigator = navigator.clone(); + let ident_value = ident(); + + spawn(async move { + use jacquard::prelude::*; + use jacquard::types::string::{Datetime, Nsid}; + + saving.set(true); + error.set(None); + + let client = fetcher.get_client(); + + // Get current DID. + let did = match fetcher.current_did().await { + Some(d) => d, + None => { + error.set(Some("Not authenticated".to_string())); + saving.set(false); + return; + } + }; + + // Build the Book record. + let now = Datetime::now(); + + // Create author from current DID. + let author = Author::new().did(did.clone()).build(); + + // Generate path from title if empty. + let path = if form_state.path.is_empty() { + slugify(&form_state.title) + } else { + form_state.path.clone() + }; + let path_for_navigation = path.clone(); + + // Build tags if present. + let tags: Option> = if form_state.tags.is_empty() { + None + } else { + Some(form_state.tags.iter().map(|s| s.as_str().into()).collect()) + }; + + // Build the notebook. + let book = Book::new() + .authors(vec![author]) + .entry_list(vec![]) // New notebook starts empty. + .maybe_title(Some(form_state.title.as_str().into())) + .maybe_path(Some(path.as_str().into())) + .maybe_publish_global(Some(form_state.publish_global)) + .maybe_tags(tags) + .created_at(now.clone()) + .updated_at(now) + .build(); + + // Serialize to Data. + let book_data = match jacquard::to_data(&book) { + Ok(d) => d, + Err(e) => { + error.set(Some(format!("Failed to serialize notebook: {:?}", e))); + saving.set(false); + return; + } + }; + + // Create the record. + let request = CreateRecord::new() + .repo(AtIdentifier::Did(did.clone())) + .collection(Nsid::new_static("sh.weaver.notebook.book").unwrap()) + .record(book_data) + .build(); + + match client.send(request).await { + Ok(resp) => { + match resp.into_output() { + Ok(_output) => { + // TODO: If publish_global, create site.standard.publication record (Task 8). + + // Navigate to the new notebook. + show_create_dialog.set(false); + saving.set(false); + + // Navigate using path or title. + let book_title = if path_for_navigation.is_empty() { + form_state.title.clone() + } else { + path_for_navigation + }; + navigator.push(Route::NotebookIndex { + ident: ident_value, + book_title: book_title.into(), + }); + } + Err(e) => { + error.set(Some(format!("Failed to create notebook: {:?}", e))); + saving.set(false); + } + } + } + Err(e) => { + error.set(Some(format!("Failed to create notebook: {:?}", e))); + saving.set(false); + } + } + }); + }; + + let handle_cancel = move |_| { + show_create_dialog.set(false); + error.set(None); + }; + rsx! { document::Link { rel: "stylesheet", href: PROFILE_ACTIONS_CSS } @@ -41,10 +171,9 @@ pub fn ProfileActions(ident: ReadSignal>) -> Element { } } - // TODO: New Notebook button (disabled for now) Button { variant: ButtonVariant::Outline, - disabled: true, + onclick: move |_| show_create_dialog.set(true), "New Notebook" } @@ -68,13 +197,61 @@ pub fn ProfileActions(ident: ReadSignal>) -> Element { } } } + + // Create notebook dialog. + DialogRoot { + open: show_create_dialog(), + on_open_change: move |open: bool| show_create_dialog.set(open), + DialogContent { + DialogTitle { "Create Notebook" } + DialogDescription { + "Create a new notebook to organize your entries." + } + NotebookEditor { + mode: NotebookEditorMode::Create, + on_save: handle_save, + on_cancel: handle_cancel, + saving: saving(), + error: error(), + } + } + } } } +/// Simple slug generation from title. +fn slugify(title: &str) -> String { + title + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c + } else if c.is_whitespace() || c == '-' || c == '_' { + '-' + } else { + '\0' + } + }) + .filter(|&c| c != '\0') + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + /// Mobile-friendly menubar version of profile actions. #[component] pub fn ProfileActionsMenubar(ident: ReadSignal>) -> Element { let auth_state = use_context::>(); + let fetcher = use_context::(); + let navigator = use_navigator(); + + // State for the create notebook dialog. + let mut show_create_dialog = use_signal(|| false); + let mut saving = use_signal(|| false); + let mut error = use_signal(|| None::); let is_owner = { let current_did = auth_state.read().did.clone(); @@ -88,6 +265,122 @@ pub fn ProfileActionsMenubar(ident: ReadSignal>) -> Elemen return rsx! {}; } + // Handler for saving a new notebook. + let create_fetcher = fetcher.clone(); + let handle_save = move |form_state: NotebookFormState| { + let fetcher = create_fetcher.clone(); + let navigator = navigator.clone(); + let ident_value = ident(); + + spawn(async move { + use jacquard::prelude::*; + use jacquard::types::string::{Datetime, Nsid}; + + saving.set(true); + error.set(None); + + let client = fetcher.get_client(); + + // Get current DID. + let did = match fetcher.current_did().await { + Some(d) => d, + None => { + error.set(Some("Not authenticated".to_string())); + saving.set(false); + return; + } + }; + + // Build the Book record. + let now = Datetime::now(); + + // Create author from current DID. + let author = Author::new().did(did.clone()).build(); + + // Generate path from title if empty. + let path = if form_state.path.is_empty() { + slugify(&form_state.title) + } else { + form_state.path.clone() + }; + let path_for_navigation = path.clone(); + + // Build tags if present. + let tags: Option> = if form_state.tags.is_empty() { + None + } else { + Some(form_state.tags.iter().map(|s| s.as_str().into()).collect()) + }; + + // Build the notebook. + let book = Book::new() + .authors(vec![author]) + .entry_list(vec![]) // New notebook starts empty. + .maybe_title(Some(form_state.title.as_str().into())) + .maybe_path(Some(path.as_str().into())) + .maybe_publish_global(Some(form_state.publish_global)) + .maybe_tags(tags) + .created_at(now.clone()) + .updated_at(now) + .build(); + + // Serialize to Data. + let book_data = match jacquard::to_data(&book) { + Ok(d) => d, + Err(e) => { + error.set(Some(format!("Failed to serialize notebook: {:?}", e))); + saving.set(false); + return; + } + }; + + // Create the record. + let request = CreateRecord::new() + .repo(AtIdentifier::Did(did.clone())) + .collection(Nsid::new_static("sh.weaver.notebook.book").unwrap()) + .record(book_data) + .build(); + + match client.send(request).await { + Ok(resp) => { + match resp.into_output() { + Ok(_output) => { + // TODO: If publish_global, create site.standard.publication record (Task 8). + + // Navigate to the new notebook. + show_create_dialog.set(false); + saving.set(false); + + // Navigate using path or title. + let book_title = if path_for_navigation.is_empty() { + form_state.title.clone() + } else { + path_for_navigation + }; + navigator.push(Route::NotebookIndex { + ident: ident_value, + book_title: book_title.into(), + }); + } + Err(e) => { + error.set(Some(format!("Failed to create notebook: {:?}", e))); + saving.set(false); + } + } + } + Err(e) => { + error.set(Some(format!("Failed to create notebook: {:?}", e))); + saving.set(false); + } + } + }); + }; + + let handle_cancel = move |_| { + show_create_dialog.set(false); + error.set(None); + }; + rsx! { div { class: "profile-actions-menubar", AppLink { @@ -98,6 +391,12 @@ pub fn ProfileActionsMenubar(ident: ReadSignal>) -> Elemen } } + Button { + variant: ButtonVariant::Outline, + onclick: move |_| show_create_dialog.set(true), + "New Notebook" + } + AppLink { to: AppLinkTarget::Drafts { ident: ident() }, Button { @@ -114,5 +413,24 @@ pub fn ProfileActionsMenubar(ident: ReadSignal>) -> Elemen } } } + + // Create notebook dialog. + DialogRoot { + open: show_create_dialog(), + on_open_change: move |open: bool| show_create_dialog.set(open), + DialogContent { + DialogTitle { "Create Notebook" } + DialogDescription { + "Create a new notebook to organize your entries." + } + NotebookEditor { + mode: NotebookEditorMode::Create, + on_save: handle_save, + on_cancel: handle_cancel, + saving: saving(), + error: error(), + } + } + } } } -- knotmirror 0.0.1