at main 960 lines 38 kB view raw
1//! Notebook settings view with full theme editor. 2 3use dioxus::prelude::*; 4use jacquard::client::AgentSessionExt; 5use jacquard::common::from_data; 6use jacquard::smol_str::SmolStr; 7use jacquard::types::aturi::AtUri; 8use jacquard::types::ident::AtIdentifier; 9use jacquard::types::string::Datetime; 10use jacquard::{CowStr, IntoStatic}; 11use weaver_api::com_atproto::repo::strong_ref::StrongRef; 12use weaver_api::sh_weaver::notebook::book::Book; 13use weaver_api::sh_weaver::notebook::colour_scheme::ColourScheme; 14use weaver_api::sh_weaver::notebook::theme::{ 15 Theme, ThemeDarkCodeTheme, ThemeFonts, ThemeLightCodeTheme, ThemeSpacing, 16}; 17 18use crate::Route; 19use crate::auth::AuthState; 20use crate::components::button::{Button, ButtonVariant}; 21use crate::components::notebook::{delete_publication, sync_publication}; 22use crate::components::{ColourSchemeValues, ThemeEditor, ThemeEditorValues}; 23use crate::data; 24use crate::fetch::Fetcher; 25 26const NOTEBOOK_SETTINGS_CSS: Asset = asset!("/assets/styling/notebook-settings.css"); 27 28/// Form state for notebook settings. 29#[derive(Debug, Clone, PartialEq, Default)] 30pub struct NotebookSettingsState { 31 pub title: String, 32 pub path: String, 33 pub publish_global: bool, 34 pub tags: Vec<String>, 35 pub tags_input: String, 36 pub content_warnings: Vec<String>, 37 pub rating: Option<String>, 38 pub theme: ThemeEditorValues, 39} 40 41/// Props for NotebookSettings view. 42#[derive(Props, Clone, PartialEq)] 43pub struct NotebookSettingsProps { 44 pub ident: ReadSignal<AtIdentifier<'static>>, 45 pub book_title: ReadSignal<SmolStr>, 46} 47 48/// Notebook settings page with full theme editor. 49#[component] 50pub fn NotebookSettings(props: NotebookSettingsProps) -> Element { 51 // Load notebook data. 52 let (notebook_result, notebook_data) = data::use_notebook(props.ident, props.book_title); 53 54 #[cfg(feature = "fullstack-server")] 55 let _ = notebook_result?; 56 57 let auth_state = use_context::<Signal<AuthState>>(); 58 let fetcher = use_context::<Fetcher>(); 59 let navigator = use_navigator(); 60 61 // Form state - editable copy of initial values. 62 let mut state = use_signal(NotebookSettingsState::default); 63 let mut state_initialized = use_signal(|| false); 64 let mut theme_values = use_signal(|| ThemeEditorValues::from_preset("rose-pine").unwrap_or_default()); 65 let mut theme_initialized = use_signal(|| false); 66 let mut saving = use_signal(|| false); 67 let mut error = use_signal(|| None::<String>); 68 69 // Active section for navigation. 70 let mut active_section = use_signal(|| "general".to_string()); 71 72 // Check ownership. 73 let current_did = auth_state.read().did.clone(); 74 let ident_val = props.ident.read().clone(); 75 let is_owner = match (&current_did, &ident_val) { 76 (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did, 77 _ => false, 78 }; 79 80 // Derive notebook URI and book from loaded data. 81 let notebook_uri = use_memo(move || { 82 let data = notebook_data()?; 83 let (notebook_view, _) = &data; 84 Some(notebook_view.uri.clone().into_static()) 85 }); 86 87 let current_book = use_memo(move || { 88 let data = notebook_data()?; 89 let (notebook_view, _) = &data; 90 let book: Book<'_> = from_data(&notebook_view.record).ok()?; 91 Some(book.into_static()) 92 }); 93 94 // Derive initial form state from notebook data. 95 let initial_form_state = use_memo(move || { 96 let book = current_book()?; 97 Some(NotebookSettingsState { 98 title: book 99 .title 100 .as_ref() 101 .map(|t| t.as_ref().to_string()) 102 .unwrap_or_default(), 103 path: book 104 .path 105 .as_ref() 106 .map(|p| p.as_ref().to_string()) 107 .unwrap_or_default(), 108 publish_global: book.publish_global.unwrap_or(false), 109 tags: book 110 .tags 111 .as_ref() 112 .map(|t| t.iter().map(|s| s.as_ref().to_string()).collect()) 113 .unwrap_or_default(), 114 tags_input: String::new(), 115 content_warnings: book 116 .content_warnings 117 .as_ref() 118 .map(|cw| cw.iter().map(|s| s.as_ref().to_string()).collect()) 119 .unwrap_or_default(), 120 rating: book.rating.as_ref().map(|r| r.as_ref().to_string()), 121 theme: ThemeEditorValues::default(), 122 }) 123 }); 124 125 // Load theme values from theme ref. 126 let theme_fetcher = fetcher.clone(); 127 let theme_resource = use_resource(move || { 128 let theme_ref = current_book() 129 .and_then(|b| b.theme.clone()) 130 .map(|t| t.into_static()); 131 let fetcher = theme_fetcher.clone(); 132 async move { 133 let Some(theme_ref) = theme_ref else { 134 return None; 135 }; 136 load_full_theme_values(&fetcher, &theme_ref).await.ok() 137 } 138 }); 139 140 // Initialize editable state from loaded data (once). 141 use_effect(move || { 142 if !state_initialized() { 143 if let Some(form_state) = initial_form_state() { 144 state.set(form_state); 145 state_initialized.set(true); 146 } 147 } 148 if !theme_initialized() { 149 if let Some(Some(theme_vals)) = theme_resource.read().as_ref() { 150 theme_values.set(theme_vals.clone()); 151 theme_initialized.set(true); 152 } 153 } 154 }); 155 156 if !is_owner { 157 return rsx! { 158 div { class: "notebook-settings-unauthorized", 159 h1 { "Unauthorized" } 160 p { "You don't have permission to edit this notebook's settings." } 161 } 162 }; 163 } 164 165 // Save general settings handler. 166 let save_fetcher = fetcher.clone(); 167 let handle_save = move |_| { 168 let fetcher = save_fetcher.clone(); 169 let uri = notebook_uri(); 170 let book = current_book(); 171 172 spawn(async move { 173 let Some(uri) = uri else { 174 error.set(Some("Notebook not loaded".to_string())); 175 return; 176 }; 177 let Some(existing_book) = book else { 178 error.set(Some("Notebook not loaded".to_string())); 179 return; 180 }; 181 182 saving.set(true); 183 error.set(None); 184 185 let form = state(); 186 let now = Datetime::now(); 187 188 let tags: Option<Vec<CowStr<'static>>> = if form.tags.is_empty() { 189 None 190 } else { 191 Some(form.tags.iter().map(|s| CowStr::from(s.clone())).collect()) 192 }; 193 194 use weaver_api::sh_weaver::notebook::{ContentRating, ContentWarning}; 195 196 let content_warnings: Option<Vec<ContentWarning<'static>>> = 197 if form.content_warnings.is_empty() { 198 None 199 } else { 200 Some( 201 form.content_warnings 202 .iter() 203 .map(|s| ContentWarning::from(s.clone())) 204 .collect(), 205 ) 206 }; 207 208 let path: CowStr<'static> = form.path.clone().into(); 209 let title: CowStr<'static> = form.title.clone().into(); 210 let publish_global = form.publish_global; 211 let rating: Option<ContentRating<'static>> = 212 form.rating.clone().map(|r| ContentRating::from(r)); 213 214 let client = fetcher.get_client(); 215 match client 216 .update_record::<Book>(&uri, |book| { 217 book.title = Some(title.clone()); 218 if !path.is_empty() { 219 book.path = Some(path.clone()); 220 } 221 book.publish_global = Some(publish_global); 222 book.tags = tags.clone(); 223 book.content_warnings = content_warnings.clone(); 224 book.rating = rating.clone(); 225 book.updated_at = Some(now.clone()); 226 }) 227 .await 228 { 229 Ok(_) => { 230 // Sync or delete publication based on publish_global. 231 let theme_vals = crate::components::InlineThemeValues::default(); 232 if publish_global { 233 if let Err(e) = 234 sync_publication(&fetcher, &uri, &title, &path, &theme_vals).await 235 { 236 tracing::warn!("Failed to sync publication: {:?}", e); 237 } 238 } else if let Err(e) = delete_publication(&fetcher, &uri).await { 239 tracing::warn!("Failed to delete publication: {:?}", e); 240 } 241 saving.set(false); 242 } 243 Err(e) => { 244 error.set(Some(format!("Failed to save: {:?}", e))); 245 saving.set(false); 246 } 247 } 248 }); 249 }; 250 251 rsx! { 252 document::Stylesheet { href: NOTEBOOK_SETTINGS_CSS } 253 254 div { class: "notebook-settings", 255 // Sidebar navigation. 256 nav { class: "notebook-settings-nav", 257 button { 258 class: if active_section() == "general" { "active" } else { "" }, 259 onclick: move |_| active_section.set("general".to_string()), 260 "General" 261 } 262 button { 263 class: if active_section() == "theme" { "active" } else { "" }, 264 onclick: move |_| active_section.set("theme".to_string()), 265 "Theme" 266 } 267 button { 268 class: if active_section() == "collaborators" { "active" } else { "" }, 269 onclick: move |_| active_section.set("collaborators".to_string()), 270 "Collaborators" 271 } 272 button { 273 class: if active_section() == "danger" { "active" } else { "" }, 274 onclick: move |_| active_section.set("danger".to_string()), 275 "Danger Zone" 276 } 277 } 278 279 // Content area. 280 div { class: "notebook-settings-content", 281 match active_section().as_str() { 282 "general" => rsx! { 283 GeneralSection { 284 state: state, 285 saving: saving(), 286 error: error(), 287 on_save: handle_save, 288 } 289 }, 290 "theme" => rsx! { 291 ThemeSection { 292 values: theme_values, 293 saving: saving(), 294 on_save: { 295 let fetcher = fetcher.clone(); 296 move |values: ThemeEditorValues| { 297 let fetcher = fetcher.clone(); 298 let uri = notebook_uri(); 299 let book = current_book(); 300 let values = values.clone(); 301 302 spawn(async move { 303 let Some(uri) = uri else { 304 error.set(Some("Notebook not loaded".to_string())); 305 return; 306 }; 307 let Some(existing_book) = book else { 308 error.set(Some("Notebook not loaded".to_string())); 309 return; 310 }; 311 312 saving.set(true); 313 error.set(None); 314 315 // Sync theme records. 316 match sync_full_theme(&fetcher, existing_book.theme.as_ref(), &values).await { 317 Ok(theme_result) => { 318 // Update book with new theme ref. 319 let theme_ref = StrongRef::new() 320 .uri(theme_result.theme_uri) 321 .cid(theme_result.theme_cid) 322 .build(); 323 324 let client = fetcher.get_client(); 325 let now = Datetime::now(); 326 match client 327 .update_record::<Book>(&uri, |book| { 328 book.theme = Some(theme_ref.clone()); 329 book.updated_at = Some(now.clone()); 330 }) 331 .await 332 { 333 Ok(_) => { 334 theme_values.set(values); 335 saving.set(false); 336 } 337 Err(e) => { 338 error.set(Some(format!("Failed to update book: {:?}", e))); 339 saving.set(false); 340 } 341 } 342 } 343 Err(e) => { 344 error.set(Some(format!("Failed to sync theme: {:?}", e))); 345 saving.set(false); 346 } 347 } 348 }); 349 } 350 }, 351 } 352 }, 353 "collaborators" => rsx! { 354 CollaboratorsSection {} 355 }, 356 "danger" => rsx! { 357 DangerSection { 358 notebook_uri: notebook_uri(), 359 on_deleted: { 360 let ident = ident_val.clone(); 361 move |_| { 362 navigator.push(Route::RepositoryIndex { ident: ident.clone() }); 363 } 364 }, 365 } 366 }, 367 _ => rsx! { div { "Unknown section" } }, 368 } 369 } 370 } 371 } 372} 373 374/// General settings section. 375#[component] 376fn GeneralSection( 377 state: Signal<NotebookSettingsState>, 378 saving: bool, 379 error: Option<String>, 380 on_save: EventHandler<()>, 381) -> Element { 382 let mut state = state; 383 384 rsx! { 385 div { class: "notebook-settings-section", 386 h2 { "General Settings" } 387 388 // Title field. 389 div { class: "notebook-settings-field", 390 label { "Title" } 391 input { 392 r#type: "text", 393 value: "{state.read().title}", 394 placeholder: "My Notebook", 395 oninput: move |e| state.write().title = e.value(), 396 } 397 } 398 399 // Path field. 400 div { class: "notebook-settings-field", 401 label { "Path" } 402 input { 403 r#type: "text", 404 value: "{state.read().path}", 405 placeholder: "my-notebook", 406 oninput: move |e| state.write().path = e.value(), 407 } 408 span { class: "notebook-settings-hint", 409 "URL-friendly identifier for your notebook." 410 } 411 } 412 413 // Publish globally toggle. 414 div { class: "notebook-settings-field notebook-settings-toggle", 415 label { 416 input { 417 r#type: "checkbox", 418 checked: state.read().publish_global, 419 onchange: move |e| state.write().publish_global = e.checked(), 420 } 421 " Publish globally" 422 } 423 span { class: "notebook-settings-hint", 424 "Enable cross-platform discovery via site.standard.* records." 425 } 426 } 427 428 // Tags field. 429 div { class: "notebook-settings-field", 430 label { "Tags" } 431 div { class: "notebook-settings-tags", 432 for (i, tag) in state.read().tags.iter().enumerate() { 433 span { 434 key: "{i}", 435 class: "notebook-settings-tag", 436 "{tag}" 437 button { 438 class: "notebook-settings-tag-remove", 439 onclick: move |_| { 440 state.write().tags.remove(i); 441 }, 442 "×" 443 } 444 } 445 } 446 input { 447 r#type: "text", 448 class: "notebook-settings-tags-input", 449 value: "{state.read().tags_input}", 450 placeholder: "Add tag...", 451 oninput: move |e| state.write().tags_input = e.value(), 452 onkeydown: move |e| { 453 if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { 454 e.prevent_default(); 455 let tag = state.read().tags_input.trim().to_string(); 456 if !tag.is_empty() { 457 let mut s = state.write(); 458 if !s.tags.contains(&tag) { 459 s.tags.push(tag); 460 } 461 s.tags_input.clear(); 462 } 463 } 464 }, 465 } 466 } 467 } 468 469 // Content rating. 470 div { class: "notebook-settings-field", 471 label { "Content Rating" } 472 select { 473 value: state.read().rating.clone().unwrap_or_default(), 474 onchange: move |e: Event<FormData>| { 475 let val = e.value(); 476 state.write().rating = if val.is_empty() { None } else { Some(val) }; 477 }, 478 option { value: "", "None" } 479 option { value: "general", "General" } 480 option { value: "mature", "Mature" } 481 option { value: "adult", "Adult" } 482 } 483 } 484 485 // Error display. 486 if let Some(ref err) = error { 487 div { class: "notebook-settings-error", "{err}" } 488 } 489 490 // Save button. 491 div { class: "notebook-settings-actions", 492 Button { 493 variant: ButtonVariant::Primary, 494 onclick: move |_| on_save.call(()), 495 disabled: saving, 496 if saving { "Saving..." } else { "Save Changes" } 497 } 498 } 499 } 500 } 501} 502 503/// Theme settings section with full editor. 504#[component] 505fn ThemeSection( 506 values: Signal<ThemeEditorValues>, 507 saving: bool, 508 on_save: EventHandler<ThemeEditorValues>, 509) -> Element { 510 rsx! { 511 div { class: "notebook-settings-section notebook-settings-theme", 512 h2 { "Theme Settings" } 513 p { class: "notebook-settings-description", 514 "Customize the appearance of your notebook with colours, fonts, and spacing." 515 } 516 517 ThemeEditor { 518 values: values, 519 on_save: on_save, 520 on_cancel: move |_| {}, 521 saving: saving, 522 } 523 } 524 } 525} 526 527/// Collaborators section. 528#[component] 529fn CollaboratorsSection() -> Element { 530 rsx! { 531 div { class: "notebook-settings-section", 532 h2 { "Collaborators" } 533 p { class: "notebook-settings-description", 534 "Manage who can edit this notebook." 535 } 536 537 // TODO: Integrate CollaboratorsPanel when notebook URI is available. 538 div { class: "notebook-settings-placeholder", 539 "Collaborator management coming soon." 540 } 541 } 542 } 543} 544 545/// Danger zone section. 546#[component] 547fn DangerSection(notebook_uri: Option<AtUri<'static>>, on_deleted: EventHandler<()>) -> Element { 548 let fetcher = use_context::<Fetcher>(); 549 let mut show_delete_confirm = use_signal(|| false); 550 let mut deleting = use_signal(|| false); 551 let mut delete_error = use_signal(|| None::<String>); 552 553 let delete_fetcher = fetcher.clone(); 554 let notebook_uri_for_delete = notebook_uri.clone(); 555 let handle_delete = move |_| { 556 let Some(uri) = notebook_uri_for_delete.clone() else { 557 delete_error.set(Some("Notebook not loaded".to_string())); 558 return; 559 }; 560 let fetcher = delete_fetcher.clone(); 561 562 spawn(async move { 563 deleting.set(true); 564 delete_error.set(None); 565 566 // Delete all entries first, then the book. 567 let rkey = match uri.rkey() { 568 Some(r) => match RecordKey::any(r.as_ref()) { 569 Ok(k) => k.into_static(), 570 Err(_) => { 571 delete_error.set(Some("Invalid record key".to_string())); 572 deleting.set(false); 573 return; 574 } 575 }, 576 None => { 577 delete_error.set(Some("Invalid notebook URI".to_string())); 578 deleting.set(false); 579 return; 580 } 581 }; 582 583 let client = fetcher.get_client(); 584 match client.delete_record::<Book>(rkey).await { 585 Ok(_) => { 586 deleting.set(false); 587 show_delete_confirm.set(false); 588 on_deleted.call(()); 589 } 590 Err(e) => { 591 delete_error.set(Some(format!("Failed to delete: {:?}", e))); 592 deleting.set(false); 593 } 594 } 595 }); 596 }; 597 598 rsx! { 599 div { class: "notebook-settings-section notebook-settings-danger", 600 h2 { "Danger Zone" } 601 602 if let Some(ref err) = delete_error() { 603 div { class: "notebook-settings-error", "{err}" } 604 } 605 606 div { class: "notebook-settings-danger-item", 607 div { class: "notebook-settings-danger-info", 608 h3 { "Delete Notebook" } 609 p { "Permanently delete this notebook and all its entries. This action cannot be undone." } 610 } 611 Button { 612 variant: ButtonVariant::Destructive, 613 onclick: move |_| show_delete_confirm.set(true), 614 disabled: notebook_uri.is_none(), 615 "Delete Notebook" 616 } 617 } 618 619 if show_delete_confirm() { 620 div { class: "notebook-settings-confirm-overlay", 621 div { class: "notebook-settings-confirm-dialog", 622 h3 { "Are you sure?" } 623 p { "This will permanently delete the notebook and all its entries." } 624 div { class: "notebook-settings-confirm-actions", 625 Button { 626 variant: ButtonVariant::Destructive, 627 onclick: handle_delete, 628 disabled: deleting(), 629 if deleting() { "Deleting..." } else { "Yes, Delete" } 630 } 631 Button { 632 variant: ButtonVariant::Ghost, 633 onclick: move |_| show_delete_confirm.set(false), 634 disabled: deleting(), 635 "Cancel" 636 } 637 } 638 } 639 } 640 } 641 } 642 } 643} 644 645// --- Helper functions for full theme sync --- 646 647use jacquard::types::string::{Cid, RecordKey}; 648use weaver_api::sh_weaver::notebook::colour_scheme::ColourSchemeColours; 649use weaver_common::WeaverError; 650 651/// Result of syncing theme records. 652pub struct FullThemeSyncResult { 653 pub theme_uri: AtUri<'static>, 654 pub theme_cid: Cid<'static>, 655} 656 657/// Load full theme values from existing theme records. 658async fn load_full_theme_values( 659 fetcher: &Fetcher, 660 theme_ref: &StrongRef<'_>, 661) -> Result<ThemeEditorValues, WeaverError> { 662 // Fetch Theme record. 663 let theme: Theme<'static> = fetcher 664 .fetch_record( 665 &Theme::uri(theme_ref.uri.as_ref()) 666 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, 667 ) 668 .await 669 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 670 .value 671 .into_static(); 672 673 // Fetch light ColourScheme. 674 let light_scheme: ColourScheme<'static> = fetcher 675 .fetch_record( 676 &ColourScheme::uri(theme.light_scheme.uri.as_ref()) 677 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, 678 ) 679 .await 680 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 681 .value 682 .into_static(); 683 684 // Fetch dark ColourScheme. 685 let dark_scheme: ColourScheme<'static> = fetcher 686 .fetch_record( 687 &ColourScheme::uri(theme.dark_scheme.uri.as_ref()) 688 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, 689 ) 690 .await 691 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 692 .value 693 .into_static(); 694 695 // Extract code themes. 696 let light_code_theme = match &theme.light_code_theme { 697 ThemeLightCodeTheme::CodeThemeName(name) => name.as_ref().to_string(), 698 ThemeLightCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(), 699 ThemeLightCodeTheme::Unknown(_) => "base16-ocean.light".to_string(), 700 }; 701 let dark_code_theme = match &theme.dark_code_theme { 702 ThemeDarkCodeTheme::CodeThemeName(name) => name.as_ref().to_string(), 703 ThemeDarkCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(), 704 ThemeDarkCodeTheme::Unknown(_) => "base16-ocean.dark".to_string(), 705 }; 706 707 let default_mode = theme 708 .default_theme 709 .as_ref() 710 .map(|s| s.to_string()) 711 .unwrap_or_else(|| "auto".to_string()); 712 713 fn colours_to_scheme(colours: &ColourSchemeColours<'_>) -> ColourSchemeValues { 714 ColourSchemeValues { 715 base: strip_hex(&colours.base), 716 surface: strip_hex(&colours.surface), 717 overlay: strip_hex(&colours.overlay), 718 text: strip_hex(&colours.text), 719 muted: strip_hex(&colours.muted), 720 subtle: strip_hex(&colours.subtle), 721 emphasis: strip_hex(&colours.emphasis), 722 primary: strip_hex(&colours.primary), 723 secondary: strip_hex(&colours.secondary), 724 tertiary: strip_hex(&colours.tertiary), 725 error: strip_hex(&colours.error), 726 warning: strip_hex(&colours.warning), 727 success: strip_hex(&colours.success), 728 border: strip_hex(&colours.border), 729 link: strip_hex(&colours.link), 730 highlight: strip_hex(&colours.highlight), 731 } 732 } 733 734 Ok(ThemeEditorValues { 735 light: colours_to_scheme(&light_scheme.colours), 736 dark: colours_to_scheme(&dark_scheme.colours), 737 font_body: String::new(), 738 font_heading: String::new(), 739 font_mono: String::new(), 740 spacing_base: theme.spacing.base_size.to_string(), 741 spacing_line_height: theme.spacing.line_height.to_string(), 742 spacing_scale: theme.spacing.scale.to_string(), 743 light_code_theme, 744 dark_code_theme, 745 default_mode, 746 }) 747} 748 749fn strip_hex(s: &str) -> String { 750 s.trim_start_matches('#') 751 .trim_start_matches("0x") 752 .trim_start_matches("0X") 753 .to_uppercase() 754} 755 756/// Sync full theme values to ColourScheme and Theme records. 757async fn sync_full_theme( 758 fetcher: &Fetcher, 759 existing_theme_ref: Option<&StrongRef<'_>>, 760 values: &ThemeEditorValues, 761) -> Result<FullThemeSyncResult, WeaverError> { 762 fn scheme_to_colours(scheme: &ColourSchemeValues) -> ColourSchemeColours<'static> { 763 ColourSchemeColours { 764 base: format!("#{}", scheme.base).into(), 765 surface: format!("#{}", scheme.surface).into(), 766 overlay: format!("#{}", scheme.overlay).into(), 767 text: format!("#{}", scheme.text).into(), 768 muted: format!("#{}", scheme.muted).into(), 769 subtle: format!("#{}", scheme.subtle).into(), 770 emphasis: format!("#{}", scheme.emphasis).into(), 771 primary: format!("#{}", scheme.primary).into(), 772 secondary: format!("#{}", scheme.secondary).into(), 773 tertiary: format!("#{}", scheme.tertiary).into(), 774 error: format!("#{}", scheme.error).into(), 775 warning: format!("#{}", scheme.warning).into(), 776 success: format!("#{}", scheme.success).into(), 777 border: format!("#{}", scheme.border).into(), 778 link: format!("#{}", scheme.link).into(), 779 highlight: format!("#{}", scheme.highlight).into(), 780 extra_data: None, 781 } 782 } 783 784 let light_colours = scheme_to_colours(&values.light); 785 let dark_colours = scheme_to_colours(&values.dark); 786 787 let light_code = 788 ThemeLightCodeTheme::CodeThemeName(Box::new(CowStr::from(values.light_code_theme.clone()))); 789 let dark_code = 790 ThemeDarkCodeTheme::CodeThemeName(Box::new(CowStr::from(values.dark_code_theme.clone()))); 791 let default_theme: Option<CowStr<'static>> = match values.default_mode.as_str() { 792 "light" => Some(CowStr::from("light")), 793 "dark" => Some(CowStr::from("dark")), 794 _ => None, 795 }; 796 797 if let Some(theme_ref) = existing_theme_ref { 798 // UPDATE existing records. 799 let theme_uri = &theme_ref.uri; 800 let existing_theme: Theme<'static> = fetcher 801 .fetch_record( 802 &Theme::uri(theme_uri.as_ref()) 803 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, 804 ) 805 .await 806 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 807 .value 808 .into_static(); 809 810 // Update light ColourScheme. 811 let light_scheme_rkey = 812 existing_theme.light_scheme.uri.rkey().ok_or_else(|| { 813 WeaverError::InvalidNotebook("Light scheme URI missing rkey".into()) 814 })?; 815 let light_result = fetcher 816 .put_record( 817 RecordKey::any(light_scheme_rkey.as_ref()) 818 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 819 .into_static(), 820 ColourScheme::new() 821 .name(CowStr::from("Custom Light")) 822 .variant(CowStr::from("light")) 823 .colours(light_colours) 824 .build(), 825 ) 826 .await 827 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 828 829 // Update dark ColourScheme. 830 let dark_scheme_rkey = 831 existing_theme.dark_scheme.uri.rkey().ok_or_else(|| { 832 WeaverError::InvalidNotebook("Dark scheme URI missing rkey".into()) 833 })?; 834 let dark_result = fetcher 835 .put_record( 836 RecordKey::any(dark_scheme_rkey.as_ref()) 837 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 838 .into_static(), 839 ColourScheme::new() 840 .name(CowStr::from("Custom Dark")) 841 .variant(CowStr::from("dark")) 842 .colours(dark_colours) 843 .build(), 844 ) 845 .await 846 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 847 848 // Update Theme with new CIDs. 849 let theme_rkey = theme_uri 850 .rkey() 851 .ok_or_else(|| WeaverError::InvalidNotebook("Theme URI missing rkey".into()))?; 852 let theme_result = fetcher 853 .put_record( 854 RecordKey::any(theme_rkey.as_ref()) 855 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? 856 .into_static(), 857 Theme::new() 858 .light_scheme( 859 StrongRef::new() 860 .uri(light_result.uri.clone().into_static()) 861 .cid(light_result.cid.clone().into_static()) 862 .build(), 863 ) 864 .dark_scheme( 865 StrongRef::new() 866 .uri(dark_result.uri.clone().into_static()) 867 .cid(dark_result.cid.clone().into_static()) 868 .build(), 869 ) 870 .light_code_theme(light_code) 871 .dark_code_theme(dark_code) 872 .fonts( 873 ThemeFonts::new() 874 .body(vec![]) 875 .heading(vec![]) 876 .monospace(vec![]) 877 .build(), 878 ) 879 .spacing(ThemeSpacing { 880 base_size: CowStr::from(values.spacing_base.clone()), 881 line_height: CowStr::from(values.spacing_line_height.clone()), 882 scale: CowStr::from(values.spacing_scale.clone()), 883 extra_data: None, 884 }) 885 .maybe_default_theme(default_theme) 886 .build(), 887 ) 888 .await 889 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 890 891 Ok(FullThemeSyncResult { 892 theme_uri: theme_result.uri.into_static(), 893 theme_cid: theme_result.cid.into_static(), 894 }) 895 } else { 896 // CREATE new records. 897 let light_scheme = ColourScheme::new() 898 .name(CowStr::from("Custom Light")) 899 .variant(CowStr::from("light")) 900 .colours(light_colours) 901 .build(); 902 903 let light_result = fetcher 904 .create_record(light_scheme, None) 905 .await 906 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 907 908 let dark_scheme = ColourScheme::new() 909 .name(CowStr::from("Custom Dark")) 910 .variant(CowStr::from("dark")) 911 .colours(dark_colours) 912 .build(); 913 914 let dark_result = fetcher 915 .create_record(dark_scheme, None) 916 .await 917 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 918 919 let theme = Theme::new() 920 .light_scheme( 921 StrongRef::new() 922 .uri(light_result.uri.clone().into_static()) 923 .cid(light_result.cid.clone().into_static()) 924 .build(), 925 ) 926 .dark_scheme( 927 StrongRef::new() 928 .uri(dark_result.uri.clone().into_static()) 929 .cid(dark_result.cid.clone().into_static()) 930 .build(), 931 ) 932 .light_code_theme(light_code) 933 .dark_code_theme(dark_code) 934 .fonts( 935 ThemeFonts::new() 936 .body(vec![]) 937 .heading(vec![]) 938 .monospace(vec![]) 939 .build(), 940 ) 941 .spacing(ThemeSpacing { 942 base_size: CowStr::from(values.spacing_base.clone()), 943 line_height: CowStr::from(values.spacing_line_height.clone()), 944 scale: CowStr::from(values.spacing_scale.clone()), 945 extra_data: None, 946 }) 947 .maybe_default_theme(default_theme) 948 .build(); 949 950 let theme_result = fetcher 951 .create_record(theme, None) 952 .await 953 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; 954 955 Ok(FullThemeSyncResult { 956 theme_uri: theme_result.uri.into_static(), 957 theme_cid: theme_result.cid.into_static(), 958 }) 959 } 960}