atproto blogging
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