From 2477a658ec25d50dfea379006e8241309be80e57 Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 11 Jan 2026 17:14:10 -0500 Subject: [PATCH] [weaver-app] add NotebookEditor component --- .../assets/styling/notebook-editor.css | 126 +++++++++ crates/weaver-app/src/components/mod.rs | 3 + .../src/components/notebook_editor.rs | 258 ++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 crates/weaver-app/assets/styling/notebook-editor.css create mode 100644 crates/weaver-app/src/components/notebook_editor.rs diff --git a/crates/weaver-app/assets/styling/notebook-editor.css b/crates/weaver-app/assets/styling/notebook-editor.css new file mode 100644 index 0000000..2e47942 --- /dev/null +++ b/crates/weaver-app/assets/styling/notebook-editor.css @@ -0,0 +1,126 @@ +/* Notebook editor form component */ + +.notebook-editor { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.notebook-editor-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.notebook-editor-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.notebook-editor-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.notebook-editor-field label { + font-size: 0.875rem; + font-weight: 500; +} + +.notebook-editor-field input[type="text"] { + padding: 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-base); + color: var(--color-text); + font-size: 0.875rem; +} + +.notebook-editor-field input[type="text"]:focus { + outline: 2px solid var(--color-primary); + outline-offset: -1px; +} + +.notebook-editor-hint { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.notebook-editor-toggle { + flex-direction: row; + align-items: flex-start; + gap: 0.5rem; +} + +.notebook-editor-toggle label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.notebook-editor-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-base); +} + +.notebook-editor-tag { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: var(--color-surface); + border-radius: 4px; + font-size: 0.75rem; +} + +.notebook-editor-tag-remove { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + padding: 0; + font-size: 1rem; + line-height: 1; +} + +.notebook-editor-tag-remove:hover { + color: var(--color-error); +} + +.notebook-editor-tags-input { + flex: 1; + min-width: 8rem; + border: none; + background: transparent; + font-size: 0.875rem; + color: var(--color-text); +} + +.notebook-editor-tags-input:focus { + outline: none; +} + +.notebook-editor-error { + padding: 0.75rem; + background: var(--color-error-bg, rgba(235, 111, 146, 0.1)); + border: 1px solid var(--color-error); + border-radius: 4px; + color: var(--color-error); + font-size: 0.875rem; +} + +.notebook-editor-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border); +} diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index c74756b..3663e7e 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -365,3 +365,6 @@ pub use hex_colour_input::HexColourInput; mod inline_theme_editor; pub use inline_theme_editor::{InlineThemeEditor, InlineThemeValues}; + +mod notebook_editor; +pub use notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState}; diff --git a/crates/weaver-app/src/components/notebook_editor.rs b/crates/weaver-app/src/components/notebook_editor.rs new file mode 100644 index 0000000..73300ca --- /dev/null +++ b/crates/weaver-app/src/components/notebook_editor.rs @@ -0,0 +1,258 @@ +//! Notebook create/edit form component. + +use dioxus::prelude::*; +use weaver_api::sh_weaver::notebook::book::Book; + +use crate::components::button::{Button, ButtonVariant}; +use crate::components::inline_theme_editor::{InlineThemeEditor, InlineThemeValues}; + +const NOTEBOOK_EDITOR_CSS: Asset = asset!("/assets/styling/notebook-editor.css"); + +/// Mode for the notebook editor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotebookEditorMode { + Create, + Edit, +} + +/// Form state for notebook editing. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct NotebookFormState { + pub title: String, + pub path: String, + pub publish_global: bool, + pub tags: Vec, + pub tags_input: String, + pub content_warnings: Vec, + pub rating: Option, + pub theme: InlineThemeValues, +} + +impl NotebookFormState { + /// Create form state from an existing Book record. + pub fn from_book(book: &Book<'_>) -> Self { + Self { + title: book.title.as_ref().map(|t| t.to_string()).unwrap_or_default(), + path: book.path.as_ref().map(|p| p.to_string()).unwrap_or_default(), + publish_global: book.publish_global.unwrap_or(false), + tags: book + .tags + .as_ref() + .map(|t| t.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + tags_input: String::new(), + content_warnings: book + .content_warnings + .as_ref() + .map(|cw| cw.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(), + rating: book.rating.as_ref().map(|r| r.to_string()), + theme: InlineThemeValues::default(), // TODO: Load from theme record if present. + } + } +} + +/// Props for NotebookEditor. +#[derive(Props, Clone, PartialEq)] +pub struct NotebookEditorProps { + /// Create or edit mode. + pub mode: NotebookEditorMode, + /// Initial form state (for edit mode). + #[props(default)] + pub initial_state: Option, + /// Callback on successful save. + pub on_save: EventHandler, + /// Callback on cancel. + pub on_cancel: EventHandler<()>, + /// Whether save is in progress. + #[props(default = false)] + pub saving: bool, + /// Error message to display. + #[props(default)] + pub error: Option, +} + +/// Notebook create/edit form. +#[component] +pub fn NotebookEditor(props: NotebookEditorProps) -> Element { + let mut state = use_signal(|| props.initial_state.clone().unwrap_or_default()); + + let title = match props.mode { + NotebookEditorMode::Create => "Create Notebook", + NotebookEditorMode::Edit => "Notebook Settings", + }; + + let save_label = match props.mode { + NotebookEditorMode::Create => "Create", + NotebookEditorMode::Edit => "Save", + }; + + // Generate path from title if empty. + let auto_path = { + let s = state.read(); + if s.path.is_empty() && !s.title.is_empty() { + slugify(&s.title) + } else { + s.path.clone() + } + }; + + let can_save = { + let s = state.read(); + !s.title.trim().is_empty() + }; + + rsx! { + document::Stylesheet { href: NOTEBOOK_EDITOR_CSS } + + div { class: "notebook-editor", + h3 { class: "notebook-editor-title", "{title}" } + + div { class: "notebook-editor-form", + // Title field. + div { class: "notebook-editor-field", + label { "Title" } + input { + r#type: "text", + value: "{state.read().title}", + placeholder: "My Notebook", + oninput: move |e| { + state.write().title = e.value(); + }, + } + } + + // Path field. + div { class: "notebook-editor-field", + label { "Path" } + input { + r#type: "text", + value: "{auto_path}", + placeholder: "my-notebook", + oninput: move |e| { + state.write().path = e.value(); + }, + } + span { class: "notebook-editor-hint", + "URL-friendly identifier. Auto-generated from title if empty." + } + } + + // Publish global toggle. + div { class: "notebook-editor-field notebook-editor-toggle", + label { + input { + r#type: "checkbox", + checked: state.read().publish_global, + onchange: move |e| { + state.write().publish_global = e.checked(); + }, + } + " Publish globally" + } + span { class: "notebook-editor-hint", + "Enable site.standard.* records for cross-platform discovery." + } + } + + // Tags field. + div { class: "notebook-editor-field", + label { "Tags" } + div { class: "notebook-editor-tags", + for (i, tag) in state.read().tags.iter().enumerate() { + span { + key: "{i}", + class: "notebook-editor-tag", + "{tag}" + button { + class: "notebook-editor-tag-remove", + onclick: move |_| { + state.write().tags.remove(i); + }, + "×" + } + } + } + input { + r#type: "text", + class: "notebook-editor-tags-input", + value: "{state.read().tags_input}", + placeholder: "Add tag...", + oninput: move |e| { + state.write().tags_input = e.value(); + }, + onkeydown: move |e| { + if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { + e.prevent_default(); + let tag = state.read().tags_input.trim().to_string(); + if !tag.is_empty() { + let mut s = state.write(); + if !s.tags.contains(&tag) { + s.tags.push(tag); + } + s.tags_input.clear(); + } + } + }, + } + } + } + + // Theme editor. + InlineThemeEditor { + values: state.read().theme.clone(), + onchange: move |v| { + state.write().theme = v; + }, + } + + // Error display. + if let Some(ref err) = props.error { + div { class: "notebook-editor-error", "{err}" } + } + + // Actions. + div { class: "notebook-editor-actions", + Button { + variant: ButtonVariant::Primary, + onclick: move |_| { + props.on_save.call(state.read().clone()); + }, + disabled: !can_save || props.saving, + if props.saving { "Saving..." } else { "{save_label}" } + } + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| { + props.on_cancel.call(()); + }, + disabled: props.saving, + "Cancel" + } + } + } + } + } +} + +/// Simple slug generation from title. +fn slugify(title: &str) -> String { + title + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c + } else if c.is_whitespace() || c == '-' || c == '_' { + '-' + } else { + '\0' + } + }) + .filter(|&c| c != '\0') + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} -- knotmirror 0.0.1