[weaver-app] add NotebookEditor component

Orual 2477a658 7e01a8b3

+387
+126
crates/weaver-app/assets/styling/notebook-editor.css
··· 1 + /* Notebook editor form component */ 2 + 3 + .notebook-editor { 4 + display: flex; 5 + flex-direction: column; 6 + gap: 1rem; 7 + } 8 + 9 + .notebook-editor-title { 10 + margin: 0; 11 + font-size: 1.25rem; 12 + font-weight: 600; 13 + } 14 + 15 + .notebook-editor-form { 16 + display: flex; 17 + flex-direction: column; 18 + gap: 1rem; 19 + } 20 + 21 + .notebook-editor-field { 22 + display: flex; 23 + flex-direction: column; 24 + gap: 0.25rem; 25 + } 26 + 27 + .notebook-editor-field label { 28 + font-size: 0.875rem; 29 + font-weight: 500; 30 + } 31 + 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; 39 + } 40 + 41 + .notebook-editor-field input[type="text"]:focus { 42 + outline: 2px solid var(--color-primary); 43 + outline-offset: -1px; 44 + } 45 + 46 + .notebook-editor-hint { 47 + font-size: 0.75rem; 48 + color: var(--color-text-muted); 49 + } 50 + 51 + .notebook-editor-toggle { 52 + flex-direction: row; 53 + align-items: flex-start; 54 + gap: 0.5rem; 55 + } 56 + 57 + .notebook-editor-toggle label { 58 + display: flex; 59 + align-items: center; 60 + gap: 0.5rem; 61 + cursor: pointer; 62 + } 63 + 64 + .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); 72 + } 73 + 74 + .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; 82 + } 83 + 84 + .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; 92 + } 93 + 94 + .notebook-editor-tag-remove:hover { 95 + color: var(--color-error); 96 + } 97 + 98 + .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); 105 + } 106 + 107 + .notebook-editor-tags-input:focus { 108 + outline: none; 109 + } 110 + 111 + .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; 118 + } 119 + 120 + .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); 126 + }
+3
crates/weaver-app/src/components/mod.rs
··· 365 365 366 366 mod inline_theme_editor; 367 367 pub use inline_theme_editor::{InlineThemeEditor, InlineThemeValues}; 368 + 369 + mod notebook_editor; 370 + pub use notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState};
+258
crates/weaver-app/src/components/notebook_editor.rs
··· 1 + //! Notebook create/edit form component. 2 + 3 + use dioxus::prelude::*; 4 + use weaver_api::sh_weaver::notebook::book::Book; 5 + 6 + use crate::components::button::{Button, ButtonVariant}; 7 + use crate::components::inline_theme_editor::{InlineThemeEditor, InlineThemeValues}; 8 + 9 + const NOTEBOOK_EDITOR_CSS: Asset = asset!("/assets/styling/notebook-editor.css"); 10 + 11 + /// Mode for the notebook editor. 12 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 13 + pub enum NotebookEditorMode { 14 + Create, 15 + Edit, 16 + } 17 + 18 + /// Form state for notebook editing. 19 + #[derive(Debug, Clone, PartialEq, Default)] 20 + pub struct NotebookFormState { 21 + pub title: String, 22 + pub path: String, 23 + pub publish_global: bool, 24 + pub tags: Vec<String>, 25 + pub tags_input: String, 26 + pub content_warnings: Vec<String>, 27 + pub rating: Option<String>, 28 + pub theme: InlineThemeValues, 29 + } 30 + 31 + impl NotebookFormState { 32 + /// Create form state from an existing Book record. 33 + pub fn from_book(book: &Book<'_>) -> Self { 34 + Self { 35 + title: book.title.as_ref().map(|t| t.to_string()).unwrap_or_default(), 36 + path: book.path.as_ref().map(|p| p.to_string()).unwrap_or_default(), 37 + publish_global: book.publish_global.unwrap_or(false), 38 + tags: book 39 + .tags 40 + .as_ref() 41 + .map(|t| t.iter().map(|s| s.to_string()).collect()) 42 + .unwrap_or_default(), 43 + tags_input: String::new(), 44 + content_warnings: book 45 + .content_warnings 46 + .as_ref() 47 + .map(|cw| cw.iter().map(|s| s.to_string()).collect()) 48 + .unwrap_or_default(), 49 + rating: book.rating.as_ref().map(|r| r.to_string()), 50 + theme: InlineThemeValues::default(), // TODO: Load from theme record if present. 51 + } 52 + } 53 + } 54 + 55 + /// Props for NotebookEditor. 56 + #[derive(Props, Clone, PartialEq)] 57 + pub struct NotebookEditorProps { 58 + /// Create or edit mode. 59 + pub mode: NotebookEditorMode, 60 + /// Initial form state (for edit mode). 61 + #[props(default)] 62 + pub initial_state: Option<NotebookFormState>, 63 + /// Callback on successful save. 64 + pub on_save: EventHandler<NotebookFormState>, 65 + /// Callback on cancel. 66 + pub on_cancel: EventHandler<()>, 67 + /// Whether save is in progress. 68 + #[props(default = false)] 69 + pub saving: bool, 70 + /// Error message to display. 71 + #[props(default)] 72 + pub error: Option<String>, 73 + } 74 + 75 + /// Notebook create/edit form. 76 + #[component] 77 + pub fn NotebookEditor(props: NotebookEditorProps) -> Element { 78 + let mut state = use_signal(|| props.initial_state.clone().unwrap_or_default()); 79 + 80 + let title = match props.mode { 81 + NotebookEditorMode::Create => "Create Notebook", 82 + NotebookEditorMode::Edit => "Notebook Settings", 83 + }; 84 + 85 + let save_label = match props.mode { 86 + NotebookEditorMode::Create => "Create", 87 + NotebookEditorMode::Edit => "Save", 88 + }; 89 + 90 + // Generate path from title if empty. 91 + let auto_path = { 92 + let s = state.read(); 93 + if s.path.is_empty() && !s.title.is_empty() { 94 + slugify(&s.title) 95 + } else { 96 + s.path.clone() 97 + } 98 + }; 99 + 100 + let can_save = { 101 + let s = state.read(); 102 + !s.title.trim().is_empty() 103 + }; 104 + 105 + rsx! { 106 + document::Stylesheet { href: NOTEBOOK_EDITOR_CSS } 107 + 108 + div { class: "notebook-editor", 109 + h3 { class: "notebook-editor-title", "{title}" } 110 + 111 + div { class: "notebook-editor-form", 112 + // Title field. 113 + div { class: "notebook-editor-field", 114 + label { "Title" } 115 + input { 116 + r#type: "text", 117 + value: "{state.read().title}", 118 + placeholder: "My Notebook", 119 + oninput: move |e| { 120 + state.write().title = e.value(); 121 + }, 122 + } 123 + } 124 + 125 + // Path field. 126 + div { class: "notebook-editor-field", 127 + label { "Path" } 128 + input { 129 + r#type: "text", 130 + value: "{auto_path}", 131 + placeholder: "my-notebook", 132 + oninput: move |e| { 133 + state.write().path = e.value(); 134 + }, 135 + } 136 + span { class: "notebook-editor-hint", 137 + "URL-friendly identifier. Auto-generated from title if empty." 138 + } 139 + } 140 + 141 + // Publish global toggle. 142 + div { class: "notebook-editor-field notebook-editor-toggle", 143 + label { 144 + input { 145 + r#type: "checkbox", 146 + checked: state.read().publish_global, 147 + onchange: move |e| { 148 + state.write().publish_global = e.checked(); 149 + }, 150 + } 151 + " Publish globally" 152 + } 153 + span { class: "notebook-editor-hint", 154 + "Enable site.standard.* records for cross-platform discovery." 155 + } 156 + } 157 + 158 + // Tags field. 159 + div { class: "notebook-editor-field", 160 + label { "Tags" } 161 + div { class: "notebook-editor-tags", 162 + for (i, tag) in state.read().tags.iter().enumerate() { 163 + span { 164 + key: "{i}", 165 + class: "notebook-editor-tag", 166 + "{tag}" 167 + button { 168 + class: "notebook-editor-tag-remove", 169 + onclick: move |_| { 170 + state.write().tags.remove(i); 171 + }, 172 + "×" 173 + } 174 + } 175 + } 176 + input { 177 + r#type: "text", 178 + class: "notebook-editor-tags-input", 179 + value: "{state.read().tags_input}", 180 + placeholder: "Add tag...", 181 + oninput: move |e| { 182 + state.write().tags_input = e.value(); 183 + }, 184 + onkeydown: move |e| { 185 + if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { 186 + e.prevent_default(); 187 + let tag = state.read().tags_input.trim().to_string(); 188 + if !tag.is_empty() { 189 + let mut s = state.write(); 190 + if !s.tags.contains(&tag) { 191 + s.tags.push(tag); 192 + } 193 + s.tags_input.clear(); 194 + } 195 + } 196 + }, 197 + } 198 + } 199 + } 200 + 201 + // Theme editor. 202 + InlineThemeEditor { 203 + values: state.read().theme.clone(), 204 + onchange: move |v| { 205 + state.write().theme = v; 206 + }, 207 + } 208 + 209 + // Error display. 210 + if let Some(ref err) = props.error { 211 + div { class: "notebook-editor-error", "{err}" } 212 + } 213 + 214 + // Actions. 215 + div { class: "notebook-editor-actions", 216 + Button { 217 + variant: ButtonVariant::Primary, 218 + onclick: move |_| { 219 + props.on_save.call(state.read().clone()); 220 + }, 221 + disabled: !can_save || props.saving, 222 + if props.saving { "Saving..." } else { "{save_label}" } 223 + } 224 + Button { 225 + variant: ButtonVariant::Ghost, 226 + onclick: move |_| { 227 + props.on_cancel.call(()); 228 + }, 229 + disabled: props.saving, 230 + "Cancel" 231 + } 232 + } 233 + } 234 + } 235 + } 236 + } 237 + 238 + /// Simple slug generation from title. 239 + fn slugify(title: &str) -> String { 240 + title 241 + .to_lowercase() 242 + .chars() 243 + .map(|c| { 244 + if c.is_ascii_alphanumeric() { 245 + c 246 + } else if c.is_whitespace() || c == '-' || c == '_' { 247 + '-' 248 + } else { 249 + '\0' 250 + } 251 + }) 252 + .filter(|&c| c != '\0') 253 + .collect::<String>() 254 + .split('-') 255 + .filter(|s| !s.is_empty()) 256 + .collect::<Vec<_>>() 257 + .join("-") 258 + }