at main 331 lines 14 kB view raw
1//! Notebook create/edit form component. 2 3use dioxus::prelude::*; 4use weaver_api::sh_weaver::notebook::book::Book; 5use weaver_common::slugify; 6 7use crate::components::button::{Button, ButtonVariant}; 8use crate::components::inline_theme_editor::{InlineThemeEditor, InlineThemeValues}; 9 10const NOTEBOOK_EDITOR_CSS: Asset = asset!("/assets/styling/notebook-editor.css"); 11 12/// Mode for the notebook editor. 13#[derive(Debug, Clone, Copy, PartialEq, Eq)] 14pub enum NotebookEditorMode { 15 Create, 16 Edit, 17} 18 19/// Form state for notebook editing. 20#[derive(Debug, Clone, PartialEq, Default)] 21pub struct NotebookFormState { 22 pub title: String, 23 pub path: String, 24 pub publish_global: bool, 25 pub tags: Vec<String>, 26 pub tags_input: String, 27 pub content_warnings: Vec<String>, 28 pub content_warnings_input: String, 29 pub rating: Option<String>, 30 pub theme: InlineThemeValues, 31} 32 33impl NotebookFormState { 34 /// Create form state from an existing Book record. 35 pub fn from_book(book: &Book<'_>) -> Self { 36 Self::from_book_with_theme(book, None) 37 } 38 39 /// Create form state with pre-loaded theme values. 40 pub fn from_book_with_theme(book: &Book<'_>, theme: Option<InlineThemeValues>) -> Self { 41 Self { 42 title: book.title.as_ref().map(|t| t.to_string()).unwrap_or_default(), 43 path: book.path.as_ref().map(|p| p.to_string()).unwrap_or_default(), 44 publish_global: book.publish_global.unwrap_or(false), 45 tags: book 46 .tags 47 .as_ref() 48 .map(|t| t.iter().map(|s| s.to_string()).collect()) 49 .unwrap_or_default(), 50 tags_input: String::new(), 51 content_warnings: book 52 .content_warnings 53 .as_ref() 54 .map(|cw| cw.iter().map(|s| s.to_string()).collect()) 55 .unwrap_or_default(), 56 content_warnings_input: String::new(), 57 rating: book.rating.as_ref().map(|r| r.to_string()), 58 theme: theme.unwrap_or_default(), 59 } 60 } 61} 62 63/// Props for NotebookEditor. 64#[derive(Props, Clone, PartialEq)] 65pub struct NotebookEditorProps { 66 /// Create or edit mode. 67 pub mode: NotebookEditorMode, 68 /// Initial form state (for edit mode). 69 #[props(default)] 70 pub initial_state: Option<NotebookFormState>, 71 /// Callback on successful save. 72 pub on_save: EventHandler<NotebookFormState>, 73 /// Callback on cancel. 74 pub on_cancel: EventHandler<()>, 75 /// Whether save is in progress. 76 #[props(default = false)] 77 pub saving: bool, 78 /// Error message to display. 79 #[props(default)] 80 pub error: Option<String>, 81 /// Control advanced theme options visibility. 82 /// None = auto (container query), Some(true) = always show, Some(false) = always hide. 83 #[props(default)] 84 pub show_advanced_theme: Option<bool>, 85 /// Show content settings (content warnings, rating). 86 #[props(default = false)] 87 pub show_content_settings: bool, 88} 89 90/// Notebook create/edit form. 91#[component] 92pub fn NotebookEditor(props: NotebookEditorProps) -> Element { 93 let mut state = use_signal(|| props.initial_state.clone().unwrap_or_default()); 94 95 // Separate signal for theme editor (InlineThemeEditor writes directly to this). 96 let theme_values = use_signal(|| state.read().theme.clone()); 97 98 let save_label = match props.mode { 99 NotebookEditorMode::Create => "Create", 100 NotebookEditorMode::Edit => "Save", 101 }; 102 103 // Generate path from title if empty. 104 let auto_path = { 105 let s = state.read(); 106 if s.path.is_empty() && !s.title.is_empty() { 107 slugify(&s.title) 108 } else { 109 s.path.clone() 110 } 111 }; 112 113 let can_save = { 114 let s = state.read(); 115 !s.title.trim().is_empty() 116 }; 117 118 rsx! { 119 document::Stylesheet { href: NOTEBOOK_EDITOR_CSS } 120 121 div { class: "notebook-editor", 122 div { class: "notebook-editor-form", 123 // Top section - two columns when wide. 124 div { class: "notebook-editor-top", 125 // Left column: title, path. 126 div { class: "notebook-editor-top-left", 127 // Title field. 128 div { class: "notebook-editor-field", 129 label { "Title" } 130 input { 131 r#type: "text", 132 value: "{state.read().title}", 133 placeholder: "My Notebook", 134 oninput: move |e| { 135 state.write().title = e.value(); 136 }, 137 } 138 } 139 140 // Path field. 141 div { class: "notebook-editor-field", 142 label { "Path" } 143 input { 144 r#type: "text", 145 value: "{auto_path}", 146 placeholder: "my-notebook", 147 oninput: move |e| { 148 state.write().path = e.value(); 149 }, 150 } 151 span { class: "notebook-editor-hint", 152 "URL-friendly identifier. Auto-generated from title if empty." 153 } 154 } 155 } 156 157 // Right column: publish globally, tags. 158 div { class: "notebook-editor-top-right", 159 // Publish global toggle. 160 div { class: "notebook-editor-field notebook-editor-toggle", 161 label { 162 input { 163 r#type: "checkbox", 164 checked: state.read().publish_global, 165 onchange: move |e| { 166 state.write().publish_global = e.checked(); 167 }, 168 } 169 " Publish globally" 170 } 171 span { class: "notebook-editor-hint", 172 "Enable site.standard.* records for cross-platform discovery." 173 } 174 } 175 176 // Tags field. 177 div { class: "notebook-editor-field", 178 label { "Tags" } 179 div { class: "notebook-editor-tags", 180 for (i, tag) in state.read().tags.iter().enumerate() { 181 span { 182 key: "{i}", 183 class: "notebook-editor-tag", 184 "{tag}" 185 button { 186 class: "notebook-editor-tag-remove", 187 onclick: move |_| { 188 state.write().tags.remove(i); 189 }, 190 "×" 191 } 192 } 193 } 194 input { 195 r#type: "text", 196 class: "notebook-editor-tags-input", 197 value: "{state.read().tags_input}", 198 placeholder: "Add tag...", 199 oninput: move |e| { 200 state.write().tags_input = e.value(); 201 }, 202 onkeydown: move |e| { 203 if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { 204 e.prevent_default(); 205 let tag = state.read().tags_input.trim().to_string(); 206 if !tag.is_empty() { 207 let mut s = state.write(); 208 if !s.tags.contains(&tag) { 209 s.tags.push(tag); 210 } 211 s.tags_input.clear(); 212 } 213 } 214 }, 215 } 216 } 217 } 218 } 219 } 220 221 // Theme editor. 222 InlineThemeEditor { 223 values: theme_values, 224 show_advanced: props.show_advanced_theme, 225 show_preview: true, 226 } 227 228 // Content settings (optional). 229 if props.show_content_settings { 230 div { class: "notebook-editor-content-settings", 231 h4 { "Content Settings" } 232 233 // Content warnings field. 234 div { class: "notebook-editor-field", 235 label { "Content Warnings" } 236 div { class: "notebook-editor-tags", 237 for (i, warning) in state.read().content_warnings.iter().enumerate() { 238 span { 239 key: "{i}", 240 class: "notebook-editor-tag notebook-editor-warning", 241 "{warning}" 242 button { 243 class: "notebook-editor-tag-remove", 244 onclick: move |_| { 245 state.write().content_warnings.remove(i); 246 }, 247 "×" 248 } 249 } 250 } 251 input { 252 r#type: "text", 253 class: "notebook-editor-tags-input", 254 value: "{state.read().content_warnings_input}", 255 placeholder: "Add warning...", 256 oninput: move |e| { 257 state.write().content_warnings_input = e.value(); 258 }, 259 onkeydown: move |e| { 260 if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { 261 e.prevent_default(); 262 let warning = state.read().content_warnings_input.trim().to_string(); 263 if !warning.is_empty() { 264 let mut s = state.write(); 265 if !s.content_warnings.contains(&warning) { 266 s.content_warnings.push(warning); 267 } 268 s.content_warnings_input.clear(); 269 } 270 } 271 }, 272 } 273 } 274 span { class: "notebook-editor-hint", 275 "Add content warnings for sensitive material (e.g., violence, adult themes)." 276 } 277 } 278 279 // Rating field. 280 div { class: "notebook-editor-field", 281 label { "Content Rating" } 282 select { 283 value: state.read().rating.clone().unwrap_or_default(), 284 onchange: move |e: Event<FormData>| { 285 let val = e.value(); 286 state.write().rating = if val.is_empty() { None } else { Some(val) }; 287 }, 288 option { value: "", "None" } 289 option { value: "general", "General" } 290 option { value: "teen", "Teen" } 291 option { value: "mature", "Mature" } 292 option { value: "adult", "Adult" } 293 } 294 span { class: "notebook-editor-hint", 295 "Age-appropriateness rating for your notebook's content." 296 } 297 } 298 } 299 } 300 301 // Error display. 302 if let Some(ref err) = props.error { 303 div { class: "notebook-editor-error", "{err}" } 304 } 305 306 // Actions. 307 div { class: "notebook-editor-actions", 308 Button { 309 variant: ButtonVariant::Primary, 310 onclick: move |_| { 311 let mut form_state = state.read().clone(); 312 form_state.theme = theme_values(); 313 props.on_save.call(form_state); 314 }, 315 disabled: !can_save || props.saving, 316 if props.saving { "Saving..." } else { "{save_label}" } 317 } 318 Button { 319 variant: ButtonVariant::Ghost, 320 onclick: move |_| { 321 props.on_cancel.call(()); 322 }, 323 disabled: props.saving, 324 "Cancel" 325 } 326 } 327 } 328 } 329 } 330} 331