[weaver-app] enable New Notebook button with creation dialog

Orual 9dbd258c 2477a658

+388 -74
+67 -71
crates/weaver-app/assets/styling/notebook-editor.css
··· 1 1 /* Notebook editor form component */ 2 2 3 3 .notebook-editor { 4 - display: flex; 5 - flex-direction: column; 6 - gap: 1rem; 4 + display: flex; 5 + flex-direction: column; 6 + gap: 1rem; 7 7 } 8 8 9 9 .notebook-editor-title { 10 - margin: 0; 11 - font-size: 1.25rem; 12 - font-weight: 600; 10 + margin: 0; 11 + font-size: 1.25rem; 12 + font-weight: 600; 13 13 } 14 14 15 15 .notebook-editor-form { 16 - display: flex; 17 - flex-direction: column; 18 - gap: 1rem; 16 + display: flex; 17 + flex-direction: column; 18 + gap: 1rem; 19 19 } 20 20 21 21 .notebook-editor-field { 22 - display: flex; 23 - flex-direction: column; 24 - gap: 0.25rem; 22 + display: flex; 23 + flex-direction: column; 24 + gap: 0.25rem; 25 25 } 26 26 27 27 .notebook-editor-field label { 28 - font-size: 0.875rem; 29 - font-weight: 500; 28 + font-size: 0.875rem; 29 + font-weight: 500; 30 30 } 31 31 32 32 .notebook-editor-field input[type="text"] { 33 - padding: 0.5rem; 34 - border: 1px solid var(--color-border); 35 - border-radius: 4px; 36 - background: var(--color-base); 37 - color: var(--color-text); 38 - font-size: 0.875rem; 33 + padding: 0.5rem; 34 + border: 1px solid var(--color-border); 35 + background: var(--color-base); 36 + color: var(--color-text); 37 + font-size: 0.875rem; 39 38 } 40 39 41 40 .notebook-editor-field input[type="text"]:focus { 42 - outline: 2px solid var(--color-primary); 43 - outline-offset: -1px; 41 + outline: 2px solid var(--color-primary); 42 + outline-offset: -1px; 44 43 } 45 44 46 45 .notebook-editor-hint { 47 - font-size: 0.75rem; 48 - color: var(--color-text-muted); 46 + font-size: 0.75rem; 47 + color: var(--color-muted); 49 48 } 50 49 51 50 .notebook-editor-toggle { 52 - flex-direction: row; 53 - align-items: flex-start; 54 - gap: 0.5rem; 51 + flex-direction: row; 52 + align-items: flex-start; 53 + gap: 0.5rem; 55 54 } 56 55 57 56 .notebook-editor-toggle label { 58 - display: flex; 59 - align-items: center; 60 - gap: 0.5rem; 61 - cursor: pointer; 57 + display: flex; 58 + align-items: center; 59 + gap: 0.5rem; 60 + cursor: pointer; 62 61 } 63 62 64 63 .notebook-editor-tags { 65 - display: flex; 66 - flex-wrap: wrap; 67 - gap: 0.5rem; 68 - padding: 0.5rem; 69 - border: 1px solid var(--color-border); 70 - border-radius: 4px; 71 - background: var(--color-base); 64 + display: flex; 65 + flex-wrap: wrap; 66 + gap: 0.5rem; 67 + padding: 0.5rem; 68 + border: 1px solid var(--color-border); 69 + background: var(--color-base); 72 70 } 73 71 74 72 .notebook-editor-tag { 75 - display: flex; 76 - align-items: center; 77 - gap: 0.25rem; 78 - padding: 0.25rem 0.5rem; 79 - background: var(--color-surface); 80 - border-radius: 4px; 81 - font-size: 0.75rem; 73 + display: flex; 74 + align-items: center; 75 + gap: 0.25rem; 76 + padding: 0.25rem 0.5rem; 77 + background: var(--color-surface); 78 + font-size: 0.75rem; 82 79 } 83 80 84 81 .notebook-editor-tag-remove { 85 - background: none; 86 - border: none; 87 - color: var(--color-text-muted); 88 - cursor: pointer; 89 - padding: 0; 90 - font-size: 1rem; 91 - line-height: 1; 82 + background: none; 83 + border: none; 84 + color: var(--color-muted); 85 + cursor: pointer; 86 + padding: 0; 87 + font-size: 1rem; 88 + line-height: 1; 92 89 } 93 90 94 91 .notebook-editor-tag-remove:hover { 95 - color: var(--color-error); 92 + color: var(--color-error); 96 93 } 97 94 98 95 .notebook-editor-tags-input { 99 - flex: 1; 100 - min-width: 8rem; 101 - border: none; 102 - background: transparent; 103 - font-size: 0.875rem; 104 - color: var(--color-text); 96 + flex: 1; 97 + min-width: 8rem; 98 + border: none; 99 + background: transparent; 100 + font-size: 0.875rem; 101 + color: var(--color-text); 105 102 } 106 103 107 104 .notebook-editor-tags-input:focus { 108 - outline: none; 105 + outline: none; 109 106 } 110 107 111 108 .notebook-editor-error { 112 - padding: 0.75rem; 113 - background: var(--color-error-bg, rgba(235, 111, 146, 0.1)); 114 - border: 1px solid var(--color-error); 115 - border-radius: 4px; 116 - color: var(--color-error); 117 - font-size: 0.875rem; 109 + padding: 0.75rem; 110 + background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); 111 + border: 1px solid var(--color-error); 112 + color: var(--color-error); 113 + font-size: 0.875rem; 118 114 } 119 115 120 116 .notebook-editor-actions { 121 - display: flex; 122 - gap: 0.5rem; 123 - justify-content: flex-end; 124 - padding-top: 0.5rem; 125 - border-top: 1px solid var(--color-border); 117 + display: flex; 118 + gap: 0.5rem; 119 + justify-content: flex-end; 120 + padding-top: 0.5rem; 121 + border-top: 1px solid var(--color-border); 126 122 }
+321 -3
crates/weaver-app/src/components/profile_actions.rs
··· 3 3 use crate::auth::AuthState; 4 4 use crate::components::app_link::{AppLink, AppLinkTarget}; 5 5 use crate::components::button::{Button, ButtonVariant}; 6 + use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; 7 + use crate::components::notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState}; 8 + use crate::fetch::Fetcher; 9 + use crate::Route; 6 10 use dioxus::prelude::*; 7 11 use jacquard::types::ident::AtIdentifier; 12 + use weaver_api::com_atproto::repo::create_record::CreateRecord; 13 + use weaver_api::sh_weaver::actor::Author; 14 + use weaver_api::sh_weaver::notebook::book::Book; 8 15 9 16 const PROFILE_ACTIONS_CSS: Asset = asset!("/assets/styling/profile-actions.css"); 10 17 ··· 12 19 #[component] 13 20 pub fn ProfileActions(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 14 21 let auth_state = use_context::<Signal<AuthState>>(); 22 + let fetcher = use_context::<Fetcher>(); 23 + let navigator = use_navigator(); 15 24 16 - // Check if viewing own profile 25 + // State for the create notebook dialog. 26 + let mut show_create_dialog = use_signal(|| false); 27 + let mut saving = use_signal(|| false); 28 + let mut error = use_signal(|| None::<String>); 29 + 30 + // Check if viewing own profile. 17 31 let is_owner = { 18 32 let current_did = auth_state.read().did.clone(); 19 33 match (&current_did, ident()) { ··· 26 40 return rsx! {}; 27 41 } 28 42 43 + // Handler for saving a new notebook. 44 + let create_fetcher = fetcher.clone(); 45 + let handle_save = move |form_state: NotebookFormState| { 46 + let fetcher = create_fetcher.clone(); 47 + let navigator = navigator.clone(); 48 + let ident_value = ident(); 49 + 50 + spawn(async move { 51 + use jacquard::prelude::*; 52 + use jacquard::types::string::{Datetime, Nsid}; 53 + 54 + saving.set(true); 55 + error.set(None); 56 + 57 + let client = fetcher.get_client(); 58 + 59 + // Get current DID. 60 + let did = match fetcher.current_did().await { 61 + Some(d) => d, 62 + None => { 63 + error.set(Some("Not authenticated".to_string())); 64 + saving.set(false); 65 + return; 66 + } 67 + }; 68 + 69 + // Build the Book record. 70 + let now = Datetime::now(); 71 + 72 + // Create author from current DID. 73 + let author = Author::new().did(did.clone()).build(); 74 + 75 + // Generate path from title if empty. 76 + let path = if form_state.path.is_empty() { 77 + slugify(&form_state.title) 78 + } else { 79 + form_state.path.clone() 80 + }; 81 + let path_for_navigation = path.clone(); 82 + 83 + // Build tags if present. 84 + let tags: Option<Vec<_>> = if form_state.tags.is_empty() { 85 + None 86 + } else { 87 + Some(form_state.tags.iter().map(|s| s.as_str().into()).collect()) 88 + }; 89 + 90 + // Build the notebook. 91 + let book = Book::new() 92 + .authors(vec![author]) 93 + .entry_list(vec![]) // New notebook starts empty. 94 + .maybe_title(Some(form_state.title.as_str().into())) 95 + .maybe_path(Some(path.as_str().into())) 96 + .maybe_publish_global(Some(form_state.publish_global)) 97 + .maybe_tags(tags) 98 + .created_at(now.clone()) 99 + .updated_at(now) 100 + .build(); 101 + 102 + // Serialize to Data. 103 + let book_data = match jacquard::to_data(&book) { 104 + Ok(d) => d, 105 + Err(e) => { 106 + error.set(Some(format!("Failed to serialize notebook: {:?}", e))); 107 + saving.set(false); 108 + return; 109 + } 110 + }; 111 + 112 + // Create the record. 113 + let request = CreateRecord::new() 114 + .repo(AtIdentifier::Did(did.clone())) 115 + .collection(Nsid::new_static("sh.weaver.notebook.book").unwrap()) 116 + .record(book_data) 117 + .build(); 118 + 119 + match client.send(request).await { 120 + Ok(resp) => { 121 + match resp.into_output() { 122 + Ok(_output) => { 123 + // TODO: If publish_global, create site.standard.publication record (Task 8). 124 + 125 + // Navigate to the new notebook. 126 + show_create_dialog.set(false); 127 + saving.set(false); 128 + 129 + // Navigate using path or title. 130 + let book_title = if path_for_navigation.is_empty() { 131 + form_state.title.clone() 132 + } else { 133 + path_for_navigation 134 + }; 135 + navigator.push(Route::NotebookIndex { 136 + ident: ident_value, 137 + book_title: book_title.into(), 138 + }); 139 + } 140 + Err(e) => { 141 + error.set(Some(format!("Failed to create notebook: {:?}", e))); 142 + saving.set(false); 143 + } 144 + } 145 + } 146 + Err(e) => { 147 + error.set(Some(format!("Failed to create notebook: {:?}", e))); 148 + saving.set(false); 149 + } 150 + } 151 + }); 152 + }; 153 + 154 + let handle_cancel = move |_| { 155 + show_create_dialog.set(false); 156 + error.set(None); 157 + }; 158 + 29 159 rsx! { 30 160 document::Link { rel: "stylesheet", href: PROFILE_ACTIONS_CSS } 31 161 ··· 41 171 } 42 172 } 43 173 44 - // TODO: New Notebook button (disabled for now) 45 174 Button { 46 175 variant: ButtonVariant::Outline, 47 - disabled: true, 176 + onclick: move |_| show_create_dialog.set(true), 48 177 "New Notebook" 49 178 } 50 179 ··· 68 197 } 69 198 } 70 199 } 200 + 201 + // Create notebook dialog. 202 + DialogRoot { 203 + open: show_create_dialog(), 204 + on_open_change: move |open: bool| show_create_dialog.set(open), 205 + DialogContent { 206 + DialogTitle { "Create Notebook" } 207 + DialogDescription { 208 + "Create a new notebook to organize your entries." 209 + } 210 + NotebookEditor { 211 + mode: NotebookEditorMode::Create, 212 + on_save: handle_save, 213 + on_cancel: handle_cancel, 214 + saving: saving(), 215 + error: error(), 216 + } 217 + } 218 + } 71 219 } 72 220 } 73 221 222 + /// Simple slug generation from title. 223 + fn slugify(title: &str) -> String { 224 + title 225 + .to_lowercase() 226 + .chars() 227 + .map(|c| { 228 + if c.is_ascii_alphanumeric() { 229 + c 230 + } else if c.is_whitespace() || c == '-' || c == '_' { 231 + '-' 232 + } else { 233 + '\0' 234 + } 235 + }) 236 + .filter(|&c| c != '\0') 237 + .collect::<String>() 238 + .split('-') 239 + .filter(|s| !s.is_empty()) 240 + .collect::<Vec<_>>() 241 + .join("-") 242 + } 243 + 74 244 /// Mobile-friendly menubar version of profile actions. 75 245 #[component] 76 246 pub fn ProfileActionsMenubar(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 77 247 let auth_state = use_context::<Signal<AuthState>>(); 248 + let fetcher = use_context::<Fetcher>(); 249 + let navigator = use_navigator(); 250 + 251 + // State for the create notebook dialog. 252 + let mut show_create_dialog = use_signal(|| false); 253 + let mut saving = use_signal(|| false); 254 + let mut error = use_signal(|| None::<String>); 78 255 79 256 let is_owner = { 80 257 let current_did = auth_state.read().did.clone(); ··· 88 265 return rsx! {}; 89 266 } 90 267 268 + // Handler for saving a new notebook. 269 + let create_fetcher = fetcher.clone(); 270 + let handle_save = move |form_state: NotebookFormState| { 271 + let fetcher = create_fetcher.clone(); 272 + let navigator = navigator.clone(); 273 + let ident_value = ident(); 274 + 275 + spawn(async move { 276 + use jacquard::prelude::*; 277 + use jacquard::types::string::{Datetime, Nsid}; 278 + 279 + saving.set(true); 280 + error.set(None); 281 + 282 + let client = fetcher.get_client(); 283 + 284 + // Get current DID. 285 + let did = match fetcher.current_did().await { 286 + Some(d) => d, 287 + None => { 288 + error.set(Some("Not authenticated".to_string())); 289 + saving.set(false); 290 + return; 291 + } 292 + }; 293 + 294 + // Build the Book record. 295 + let now = Datetime::now(); 296 + 297 + // Create author from current DID. 298 + let author = Author::new().did(did.clone()).build(); 299 + 300 + // Generate path from title if empty. 301 + let path = if form_state.path.is_empty() { 302 + slugify(&form_state.title) 303 + } else { 304 + form_state.path.clone() 305 + }; 306 + let path_for_navigation = path.clone(); 307 + 308 + // Build tags if present. 309 + let tags: Option<Vec<_>> = if form_state.tags.is_empty() { 310 + None 311 + } else { 312 + Some(form_state.tags.iter().map(|s| s.as_str().into()).collect()) 313 + }; 314 + 315 + // Build the notebook. 316 + let book = Book::new() 317 + .authors(vec![author]) 318 + .entry_list(vec![]) // New notebook starts empty. 319 + .maybe_title(Some(form_state.title.as_str().into())) 320 + .maybe_path(Some(path.as_str().into())) 321 + .maybe_publish_global(Some(form_state.publish_global)) 322 + .maybe_tags(tags) 323 + .created_at(now.clone()) 324 + .updated_at(now) 325 + .build(); 326 + 327 + // Serialize to Data. 328 + let book_data = match jacquard::to_data(&book) { 329 + Ok(d) => d, 330 + Err(e) => { 331 + error.set(Some(format!("Failed to serialize notebook: {:?}", e))); 332 + saving.set(false); 333 + return; 334 + } 335 + }; 336 + 337 + // Create the record. 338 + let request = CreateRecord::new() 339 + .repo(AtIdentifier::Did(did.clone())) 340 + .collection(Nsid::new_static("sh.weaver.notebook.book").unwrap()) 341 + .record(book_data) 342 + .build(); 343 + 344 + match client.send(request).await { 345 + Ok(resp) => { 346 + match resp.into_output() { 347 + Ok(_output) => { 348 + // TODO: If publish_global, create site.standard.publication record (Task 8). 349 + 350 + // Navigate to the new notebook. 351 + show_create_dialog.set(false); 352 + saving.set(false); 353 + 354 + // Navigate using path or title. 355 + let book_title = if path_for_navigation.is_empty() { 356 + form_state.title.clone() 357 + } else { 358 + path_for_navigation 359 + }; 360 + navigator.push(Route::NotebookIndex { 361 + ident: ident_value, 362 + book_title: book_title.into(), 363 + }); 364 + } 365 + Err(e) => { 366 + error.set(Some(format!("Failed to create notebook: {:?}", e))); 367 + saving.set(false); 368 + } 369 + } 370 + } 371 + Err(e) => { 372 + error.set(Some(format!("Failed to create notebook: {:?}", e))); 373 + saving.set(false); 374 + } 375 + } 376 + }); 377 + }; 378 + 379 + let handle_cancel = move |_| { 380 + show_create_dialog.set(false); 381 + error.set(None); 382 + }; 383 + 91 384 rsx! { 92 385 div { class: "profile-actions-menubar", 93 386 AppLink { ··· 98 391 } 99 392 } 100 393 394 + Button { 395 + variant: ButtonVariant::Outline, 396 + onclick: move |_| show_create_dialog.set(true), 397 + "New Notebook" 398 + } 399 + 101 400 AppLink { 102 401 to: AppLinkTarget::Drafts { ident: ident() }, 103 402 Button { ··· 111 410 Button { 112 411 variant: ButtonVariant::Ghost, 113 412 "Invites" 413 + } 414 + } 415 + } 416 + 417 + // Create notebook dialog. 418 + DialogRoot { 419 + open: show_create_dialog(), 420 + on_open_change: move |open: bool| show_create_dialog.set(open), 421 + DialogContent { 422 + DialogTitle { "Create Notebook" } 423 + DialogDescription { 424 + "Create a new notebook to organize your entries." 425 + } 426 + NotebookEditor { 427 + mode: NotebookEditorMode::Create, 428 + on_save: handle_save, 429 + on_cancel: handle_cancel, 430 + saving: saving(), 431 + error: error(), 114 432 } 115 433 } 116 434 }