atproto blogging
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 (¤t_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(¬ebook_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}