diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 73169af..5fd0862 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -57,7 +57,7 @@ jacquard-identity = { workspace = true } jacquard-axum = { workspace = true, optional = true } weaver-api = { path = "../weaver-api", features = ["com_whtwnd"] } markdown-weaver = { workspace = true } -weaver-renderer = { path = "../weaver-renderer", features = ["themes"] } +weaver-renderer = { path = "../weaver-renderer", features = ["themes", "syntax-highlighting", "syntax-css"] } n0-future = { workspace = true } dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, features = ["router"] } axum = { version = "0.8.6", optional = true } diff --git a/crates/weaver-app/assets/styling/inline-theme-editor.css b/crates/weaver-app/assets/styling/inline-theme-editor.css index ad8a145..7c174fc 100644 --- a/crates/weaver-app/assets/styling/inline-theme-editor.css +++ b/crates/weaver-app/assets/styling/inline-theme-editor.css @@ -1,6 +1,7 @@ /* Inline theme editor for notebook settings */ .inline-theme-editor { + container-type: inline-size; display: flex; flex-direction: column; gap: 1rem; @@ -43,12 +44,47 @@ border-color: var(--color-primary); } +/* Main theme controls section - two columns when wide */ +.inline-theme-editor-main { + display: flex; + flex-direction: column; + gap: 1rem; +} + +@container (min-width: 32rem) { + .inline-theme-editor-main { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem 1.5rem; + } +} + +/* Left: colours and mode toggle */ +.inline-theme-editor-main-left { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Right: dropdowns */ +.inline-theme-editor-main-right { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + .inline-theme-editor-colours { display: grid; - grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + grid-template-columns: 1fr; gap: 0.75rem; } +@container (min-width: 20rem) { + .inline-theme-editor-colours { + grid-template-columns: repeat(2, 1fr); + } +} + .inline-theme-editor-code-theme, .inline-theme-editor-mode { display: flex; @@ -81,6 +117,28 @@ gap: 0.5rem; } +/* Advanced colour options - hidden by default in narrow containers */ +.inline-theme-editor-advanced { + display: none; +} + +/* Show advanced when container is wide enough */ +@container (min-width: 40rem) { + .inline-theme-editor-advanced { + display: block; + } +} + +/* Force show advanced via class (for prop override) */ +.inline-theme-editor-advanced.force-show { + display: block; +} + +/* Force hide advanced via class (for prop override) */ +.inline-theme-editor-advanced.force-hide { + display: none; +} + .inline-theme-editor-advanced-content { display: flex; flex-direction: column; @@ -91,6 +149,15 @@ border-radius: 0; } +/* Side-by-side variants when wide */ +@container (min-width: 40rem) { + .inline-theme-editor-advanced-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + } +} + .inline-theme-editor-variant { display: flex; flex-direction: column; @@ -113,3 +180,42 @@ .inline-theme-editor-full-link:hover { text-decoration: underline; } + +/* Preview section */ +.inline-theme-editor-preview { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; + border-top: 1px solid var(--color-border); + padding-top: 1rem; +} + +.inline-theme-editor-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.inline-theme-editor-preview-header h5 { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-muted); +} + +.inline-theme-editor-preview .theme-preview { + border: 1px solid var(--color-border); + border-radius: 0; + max-height: 20rem; + overflow-y: auto; + padding: 1rem; +} + +.inline-theme-editor-preview .theme-preview--loading, +.inline-theme-editor-preview .theme-preview--error { + padding: 1rem; + font-size: 0.875rem; + color: var(--color-muted); +} diff --git a/crates/weaver-app/assets/styling/notebook-editor.css b/crates/weaver-app/assets/styling/notebook-editor.css index 6dc4018..9b10f6d 100644 --- a/crates/weaver-app/assets/styling/notebook-editor.css +++ b/crates/weaver-app/assets/styling/notebook-editor.css @@ -1,6 +1,7 @@ /* Notebook editor form component */ .notebook-editor { + container-type: inline-size; display: flex; flex-direction: column; gap: 1rem; @@ -18,6 +19,35 @@ gap: 1rem; } +/* Top fields section - two columns when wide */ +.notebook-editor-top { + display: flex; + flex-direction: column; + gap: 1rem; +} + +@container (min-width: 40rem) { + .notebook-editor-top { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem 1.5rem; + } +} + +/* Left column: title, path */ +.notebook-editor-top-left { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Right column: publish globally, tags */ +.notebook-editor-top-right { + display: flex; + flex-direction: column; + gap: 1rem; +} + .notebook-editor-field { display: flex; flex-direction: column; @@ -49,9 +79,8 @@ } .notebook-editor-toggle { - flex-direction: row; - align-items: flex-start; - gap: 0.5rem; + /* Keep column direction from .notebook-editor-field, just adjust gap */ + gap: 0.25rem; } .notebook-editor-toggle label { @@ -150,3 +179,53 @@ justify-content: flex-end; padding-top: 0.5rem; } + +/* Content settings section */ +.notebook-editor-content-settings { + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.notebook-editor-content-settings h4 { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.notebook-editor-warning { + background: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface)); + border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent); +} + +.notebook-editor-field select { + padding: 0.5rem; + padding-right: 2rem; + border: 1px solid var(--color-border); + border-radius: 0; + background: var(--color-base); + color: var(--color-text); + font-size: 0.875rem; + font-family: inherit; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; + cursor: pointer; +} + +.notebook-editor-field select:focus { + outline: 2px solid var(--color-primary); + outline-offset: -1px; +} + +.notebook-editor-field select option { + background: var(--color-surface); + color: var(--color-text); + padding: 0.5rem; +} diff --git a/crates/weaver-app/assets/styling/notebook-settings.css b/crates/weaver-app/assets/styling/notebook-settings.css new file mode 100644 index 0000000..3f4e24f --- /dev/null +++ b/crates/weaver-app/assets/styling/notebook-settings.css @@ -0,0 +1,321 @@ +/* Notebook settings page */ + +.notebook-settings { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; + max-width: 100rem; + margin: 0 auto; + padding: 1.5rem; + min-height: calc(100vh - 4rem); +} + +@media (max-width: 768px) { + .notebook-settings { + grid-template-columns: 1fr; + gap: 1rem; + } +} + +/* Sidebar navigation */ +.notebook-settings-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; + position: sticky; + top: 1rem; + align-self: start; +} + +@media (max-width: 768px) { + .notebook-settings-nav { + flex-direction: row; + flex-wrap: wrap; + position: static; + border-bottom: 1px solid var(--color-border); + padding-bottom: 1rem; + } +} + +.notebook-settings-nav button { + padding: 0.75rem 1rem; + text-align: left; + background: transparent; + border: none; + color: var(--color-muted); + font-size: 0.875rem; + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.notebook-settings-nav button:hover { + color: var(--color-text); + background: var(--color-surface); +} + +.notebook-settings-nav button.active { + color: var(--color-text); + background: var(--color-surface); + font-weight: 600; +} + +/* Content area */ +.notebook-settings-content { + min-width: 0; +} + +/* Section styling */ +.notebook-settings-section { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.notebook-settings-section h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text); + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-border); +} + +.notebook-settings-description { + margin: 0; + color: var(--color-muted); + font-size: 0.875rem; +} + +/* Form fields */ +.notebook-settings-field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.notebook-settings-field label { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text); +} + +.notebook-settings-field input[type="text"], +.notebook-settings-field select { + padding: 0.625rem 0.75rem; + border: 1px solid var(--color-border); + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.875rem; +} + +.notebook-settings-field input:focus, +.notebook-settings-field select:focus { + outline: none; + border-color: var(--color-primary); +} + +.notebook-settings-hint { + font-size: 0.75rem; + color: var(--color-muted); +} + +/* Toggle field */ +.notebook-settings-toggle label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.notebook-settings-toggle input[type="checkbox"] { + width: 1rem; + height: 1rem; +} + +/* Tags */ +.notebook-settings-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem; + border: 1px solid var(--color-border); + background: var(--color-base); + min-height: 2.5rem; +} + +.notebook-settings-tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + font-size: 0.75rem; + color: var(--color-text); +} + +.notebook-settings-tag-remove { + background: none; + border: none; + padding: 0; + margin-left: 0.25rem; + color: var(--color-muted); + cursor: pointer; + font-size: 1rem; + line-height: 1; +} + +.notebook-settings-tag-remove:hover { + color: var(--color-error); +} + +.notebook-settings-tags-input { + flex: 1; + min-width: 100px; + border: none !important; + background: transparent !important; + padding: 0.25rem !important; +} + +.notebook-settings-tags-input:focus { + outline: none; +} + +/* Actions */ +.notebook-settings-actions { + display: flex; + justify-content: flex-start; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +/* Error display */ +.notebook-settings-error { + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--color-error) 10%, transparent); + border: 1px solid var(--color-error); + color: var(--color-error); + font-size: 0.875rem; +} + +/* Theme section - needs full width */ +.notebook-settings-theme { + container-type: inline-size; +} + +.notebook-settings-theme .theme-editor-page { + padding: 0; + max-width: none; +} + +/* On smaller screens, stack the theme editor */ +@container (max-width: 50rem) { + .notebook-settings-theme .theme-editor-page { + grid-template-columns: 1fr; + } + + .notebook-settings-theme .theme-editor-preview { + position: static; + max-height: none; + } +} + +/* Danger zone */ +.notebook-settings-danger h2 { + color: var(--color-error); + border-color: var(--color-error); +} + +.notebook-settings-danger-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + padding: 1rem; + background: color-mix(in srgb, var(--color-error) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent); +} + +.notebook-settings-danger-info h3 { + margin: 0 0 0.25rem; + font-size: 1rem; + font-weight: 600; + color: var(--color-text); +} + +.notebook-settings-danger-info p { + margin: 0; + font-size: 0.875rem; + color: var(--color-muted); +} + +/* Confirm dialog */ +.notebook-settings-confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.notebook-settings-confirm-dialog { + background: var(--color-surface); + border: 1px solid var(--color-border); + padding: 1.5rem; + max-width: 400px; + width: 90%; +} + +.notebook-settings-confirm-dialog h3 { + margin: 0 0 0.5rem; + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); +} + +.notebook-settings-confirm-dialog p { + margin: 0 0 1.5rem; + color: var(--color-muted); + font-size: 0.875rem; +} + +.notebook-settings-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +/* Unauthorized state */ +.notebook-settings-unauthorized { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 50vh; + text-align: center; + padding: 2rem; +} + +.notebook-settings-unauthorized h1 { + margin: 0 0 0.5rem; + font-size: 1.5rem; + color: var(--color-text); +} + +.notebook-settings-unauthorized p { + margin: 0; + color: var(--color-muted); +} + +/* Placeholder */ +.notebook-settings-placeholder { + padding: 2rem; + text-align: center; + color: var(--color-muted); + background: var(--color-surface); + border: 1px dashed var(--color-border); +} diff --git a/crates/weaver-app/assets/styling/theme-editor.css b/crates/weaver-app/assets/styling/theme-editor.css new file mode 100644 index 0000000..7cf9b87 --- /dev/null +++ b/crates/weaver-app/assets/styling/theme-editor.css @@ -0,0 +1,336 @@ +/* Full theme editor page */ + +.theme-editor-page { + display: grid; + grid-template-columns: 1fr; + gap: 2rem; + padding: 1.5rem; + max-width: 100rem; +} + +@media (min-width: 60rem) { + .theme-editor-page { + grid-template-columns: 1fr 400px; + } +} + +/* Left column: controls */ +.theme-editor-controls { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* Right column: preview */ +.theme-editor-preview { + position: sticky; + top: 1rem; + align-self: start; + max-height: calc(100vh - 2rem); + overflow-y: auto; +} + +/* Section styling */ +.theme-editor-section { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); +} + +.theme-editor-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.theme-editor-scheme-preset { + padding: 0.375rem 0.5rem; + padding-right: 1.75rem; + border: 1px solid var(--color-border); + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.75rem; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.375rem center; + cursor: pointer; +} + +.theme-editor-scheme-preset:focus { + outline: none; + border-color: var(--color-primary); +} + +.theme-editor-scheme-preset option { + background: var(--color-surface); + color: var(--color-text); +} + +.theme-editor-section h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text); +} + +/* Colour grid - 4 columns */ +.theme-editor-colours { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; +} + +@media (min-width: 30rem) { + .theme-editor-colours { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Colour groups */ +.theme-editor-colour-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.theme-editor-colour-group-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.theme-editor-colour-group-items { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; +} + +@container (min-width: 30rem) { + .theme-editor-colour-group-items { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Fonts section */ +.theme-editor-fonts { + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem; +} + +@container (min-width: 30rem) { + .theme-editor-fonts { + grid-template-columns: repeat(3, 1fr); + } +} + +.theme-editor-font-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.theme-editor-font-field label { + font-size: 0.875rem; + color: var(--color-muted); +} + +.theme-editor-font-field input, +.theme-editor-font-field select { + padding: 0.5rem; + border: 1px solid var(--color-border); + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.875rem; +} + +.theme-editor-font-field input:focus, +.theme-editor-font-field select:focus { + outline: none; + border-color: var(--color-primary); +} + +/* Spacing section */ +.theme-editor-spacing { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +.theme-editor-spacing-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.theme-editor-spacing-field label { + font-size: 0.875rem; + color: var(--color-muted); +} + +.theme-editor-spacing-field input { + padding: 0.5rem; + border: 1px solid var(--color-border); + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-mono); + font-size: 0.875rem; + text-align: center; +} + +.theme-editor-spacing-field input:focus { + outline: none; + border-color: var(--color-primary); +} + +/* Code themes */ +.theme-editor-code-themes { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.theme-editor-code-theme { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.theme-editor-code-theme label { + font-size: 0.875rem; + color: var(--color-muted); +} + +.theme-editor-code-theme select { + padding: 0.5rem; + border: 1px solid var(--color-border); + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.875rem; +} + +.theme-editor-code-theme select:focus { + outline: none; + border-color: var(--color-primary); +} + +/* Mode toggle */ +.theme-editor-mode { + display: flex; + align-items: center; + gap: 1rem; +} + +.theme-editor-mode label { + font-size: 0.875rem; + color: var(--color-muted); +} + +/* Actions row */ +.theme-editor-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +/* Preview header */ +.theme-editor-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.theme-editor-preview-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-muted); +} + +.theme-editor-preview .theme-preview { + padding: 1rem; + border: 1px solid var(--color-border); + max-width: none; +} + +.theme-editor-preview .theme-preview--loading, +.theme-editor-preview .theme-preview--error { + padding: 2rem; + text-align: center; + color: var(--color-muted); +} + +/* Preset selector */ +.theme-editor-preset { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.theme-editor-preset label { + font-size: 0.875rem; + color: var(--color-muted); +} + +.theme-editor-preset select { + padding: 0.5rem; + border: 1px solid var(--color-border); + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.875rem; +} + +.theme-editor-preset select:focus { + outline: none; + border-color: var(--color-primary); +} + +/* Back link */ +.theme-editor-back { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--color-link); + text-decoration: none; + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.theme-editor-back:hover { + text-decoration: underline; +} + +/* Select dropdown styling */ +.theme-editor-section select, +.theme-editor-preset select, +.theme-editor-code-theme select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca3af' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; + padding-right: 2rem; + cursor: pointer; +} + +.theme-editor-section select option, +.theme-editor-preset select option, +.theme-editor-code-theme select option { + background: var(--color-surface); + color: var(--color-text); + padding: 0.5rem; +} diff --git a/crates/weaver-app/src/components/css.rs b/crates/weaver-app/src/components/css.rs index 4049b0f..f5bf589 100644 --- a/crates/weaver-app/src/components/css.rs +++ b/crates/weaver-app/src/components/css.rs @@ -144,6 +144,178 @@ pub async fn css(ident: SmolStr, notebook: SmolStr) -> Result { Ok(([(CONTENT_TYPE, "text/css")], css).into_response()) } +/// Input for generating theme preview data from 4 base colours. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ThemePreviewInput { + pub background: String, + pub text: String, + pub primary: String, + pub link: String, + pub light_background: Option, + pub light_text: Option, + pub light_primary: Option, + pub light_link: Option, + pub dark_background: Option, + pub dark_text: Option, + pub dark_primary: Option, + pub dark_link: Option, + pub light_code_theme: String, + pub dark_code_theme: String, +} + +/// Generated 16-colour palette for a single variant. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct ColourPalette { + pub base: String, + pub surface: String, + pub overlay: String, + pub text: String, + pub muted: String, + pub subtle: String, + pub emphasis: String, + pub primary: String, + pub secondary: String, + pub tertiary: String, + pub error: String, + pub warning: String, + pub success: String, + pub border: String, + pub link: String, + pub highlight: String, +} + +impl ColourPalette { + pub fn to_css_vars(&self) -> String { + // Ensure all colours have # prefix. + let fmt = |c: &str| if c.starts_with('#') { c.to_string() } else { format!("#{}", c) }; + format!( + "--color-base: {}; --color-surface: {}; --color-overlay: {}; \ + --color-text: {}; --color-muted: {}; --color-subtle: {}; \ + --color-emphasis: {}; --color-primary: {}; --color-secondary: {}; \ + --color-tertiary: {}; --color-error: {}; --color-warning: {}; \ + --color-success: {}; --color-border: {}; --color-link: {}; \ + --color-highlight: {};", + fmt(&self.base), fmt(&self.surface), fmt(&self.overlay), + fmt(&self.text), fmt(&self.muted), fmt(&self.subtle), + fmt(&self.emphasis), fmt(&self.primary), fmt(&self.secondary), + fmt(&self.tertiary), fmt(&self.error), fmt(&self.warning), + fmt(&self.success), fmt(&self.border), fmt(&self.link), + fmt(&self.highlight) + ) + } +} + +/// Generated theme preview data: palettes + syntax CSS. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ThemePreviewOutput { + pub light_palette: ColourPalette, + pub dark_palette: ColourPalette, + pub syntax_css: String, +} + +/// Generate theme preview data from 4 base colours. +#[post("/api/theme-preview")] +pub async fn generate_theme_preview( + input: ThemePreviewInput, +) -> Result { + use weaver_renderer::colour_gen::{ThemeInputs, ThemeVariant, detect_variant, generate_palette, generate_counterpart_palette}; + use weaver_renderer::css::generate_syntax_css; + use weaver_renderer::theme::{ResolvedTheme, ThemeDefault, default_fonts, default_spacing, ColourSchemeColours}; + + let light_inputs = ThemeInputs { + background: input.light_background.unwrap_or_else(|| input.background.clone()), + text: input.light_text.unwrap_or_else(|| input.text.clone()), + primary: input.light_primary.unwrap_or_else(|| input.primary.clone()), + link: input.light_link.unwrap_or_else(|| input.link.clone()), + }; + + let dark_inputs = ThemeInputs { + background: input.dark_background.unwrap_or_else(|| input.background.clone()), + text: input.dark_text.unwrap_or_else(|| input.text.clone()), + primary: input.dark_primary.unwrap_or_else(|| input.primary.clone()), + link: input.dark_link.unwrap_or_else(|| input.link.clone()), + }; + + let primary_variant = detect_variant(&input.background) + .map_err(|e| ServerFnError::new(format!("Invalid background colour: {}", e)))?; + + let (light_scheme, dark_scheme): (ColourSchemeColours, ColourSchemeColours) = match primary_variant { + ThemeVariant::Light => { + let light = generate_palette(&light_inputs, ThemeVariant::Light) + .map_err(|e| ServerFnError::new(format!("Failed to generate light palette: {}", e)))?; + let dark = generate_counterpart_palette(&dark_inputs, ThemeVariant::Light) + .map_err(|e| ServerFnError::new(format!("Failed to generate dark palette: {}", e)))?; + (light, dark) + } + ThemeVariant::Dark => { + let dark = generate_palette(&dark_inputs, ThemeVariant::Dark) + .map_err(|e| ServerFnError::new(format!("Failed to generate dark palette: {}", e)))?; + let light = generate_counterpart_palette(&light_inputs, ThemeVariant::Dark) + .map_err(|e| ServerFnError::new(format!("Failed to generate light palette: {}", e)))?; + (light, dark) + } + }; + + let light_code_theme = weaver_renderer::theme::ThemeLightCodeTheme::CodeThemeName(Box::new(input.light_code_theme.into())); + let dark_code_theme = weaver_renderer::theme::ThemeDarkCodeTheme::CodeThemeName(Box::new(input.dark_code_theme.into())); + + let resolved = ResolvedTheme { + default: ThemeDefault::Auto, + dark_scheme: dark_scheme.clone(), + light_scheme: light_scheme.clone(), + fonts: default_fonts(), + spacing: default_spacing(), + dark_code_theme, + light_code_theme, + }; + + let syntax_css = generate_syntax_css(&resolved).await.unwrap_or_default(); + + let light_palette = ColourPalette { + base: light_scheme.base.to_string(), + surface: light_scheme.surface.to_string(), + overlay: light_scheme.overlay.to_string(), + text: light_scheme.text.to_string(), + muted: light_scheme.muted.to_string(), + subtle: light_scheme.subtle.to_string(), + emphasis: light_scheme.emphasis.to_string(), + primary: light_scheme.primary.to_string(), + secondary: light_scheme.secondary.to_string(), + tertiary: light_scheme.tertiary.to_string(), + error: light_scheme.error.to_string(), + warning: light_scheme.warning.to_string(), + success: light_scheme.success.to_string(), + border: light_scheme.border.to_string(), + link: light_scheme.link.to_string(), + highlight: light_scheme.highlight.to_string(), + }; + + let dark_palette = ColourPalette { + base: dark_scheme.base.to_string(), + surface: dark_scheme.surface.to_string(), + overlay: dark_scheme.overlay.to_string(), + text: dark_scheme.text.to_string(), + muted: dark_scheme.muted.to_string(), + subtle: dark_scheme.subtle.to_string(), + emphasis: dark_scheme.emphasis.to_string(), + primary: dark_scheme.primary.to_string(), + secondary: dark_scheme.secondary.to_string(), + tertiary: dark_scheme.tertiary.to_string(), + error: dark_scheme.error.to_string(), + warning: dark_scheme.warning.to_string(), + success: dark_scheme.success.to_string(), + border: dark_scheme.border.to_string(), + link: dark_scheme.link.to_string(), + highlight: dark_scheme.highlight.to_string(), + }; + + Ok(ThemePreviewOutput { + light_palette, + dark_palette, + syntax_css, + }) +} + #[cfg(feature = "server")] fn minify_css(css: &str) -> Option { use lightningcss::printer::PrinterOptions; diff --git a/crates/weaver-app/src/components/dialog/dialog.css b/crates/weaver-app/src/components/dialog/dialog.css index dff3d97..bcd53e1 100644 --- a/crates/weaver-app/src/components/dialog/dialog.css +++ b/crates/weaver-app/src/components/dialog/dialog.css @@ -80,7 +80,7 @@ @media (width >= 40rem) { .dialog { - max-width: 32rem; + max-width: 56rem; text-align: left; } } diff --git a/crates/weaver-app/src/components/hex_colour_input.rs b/crates/weaver-app/src/components/hex_colour_input.rs index 0ebc5df..b2cc8de 100644 --- a/crates/weaver-app/src/components/hex_colour_input.rs +++ b/crates/weaver-app/src/components/hex_colour_input.rs @@ -15,6 +15,9 @@ pub struct HexColourInputProps { /// Placeholder text. #[props(default = "000000".to_string())] pub placeholder: String, + /// Callback when input receives focus. + #[props(default)] + pub onfocus: Option>, } /// A hex colour input with a colour preview swatch. @@ -62,6 +65,11 @@ pub fn HexColourInput(props: HexColourInputProps) -> Element { .to_uppercase(); props.onchange.call(filtered); }, + onfocus: move |e| { + if let Some(handler) = &props.onfocus { + handler.call(e); + } + }, } } } diff --git a/crates/weaver-app/src/components/inline_theme_editor.rs b/crates/weaver-app/src/components/inline_theme_editor.rs index ff7255c..721abe9 100644 --- a/crates/weaver-app/src/components/inline_theme_editor.rs +++ b/crates/weaver-app/src/components/inline_theme_editor.rs @@ -5,8 +5,9 @@ use dioxus::prelude::*; use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES}; -use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger}; use crate::components::HexColourInput; +use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger}; +use crate::components::theme_preview::ThemePreview; use crate::components::toggle_group::{ToggleGroup, ToggleItem}; /// Strip leading # or 0x from hex colour strings. @@ -103,16 +104,27 @@ impl InlineThemeValues { /// Props for InlineThemeEditor. #[derive(Props, Clone, PartialEq)] pub struct InlineThemeEditorProps { - /// Current theme values. - pub values: InlineThemeValues, - /// Callback when any value changes. - pub onchange: EventHandler, + /// Current theme values (signal for reactivity). + pub values: Signal, + /// Control advanced options visibility. + /// None = auto (container query), Some(true) = always show, Some(false) = always hide. + #[props(default)] + pub show_advanced: Option, + /// Show live preview of theme. + #[props(default = false)] + pub show_preview: bool, } /// Inline theme editor with core colour pickers and code theme selector. #[component] pub fn InlineThemeEditor(props: InlineThemeEditorProps) -> Element { - let values = props.values.clone(); + let mut values = props.values; + + let advanced_class = match props.show_advanced { + Some(true) => "inline-theme-editor-advanced force-show", + Some(false) => "inline-theme-editor-advanced force-hide", + None => "inline-theme-editor-advanced", + }; rsx! { document::Stylesheet { href: INLINE_THEME_EDITOR_CSS } @@ -120,290 +132,203 @@ pub fn InlineThemeEditor(props: InlineThemeEditorProps) -> Element { div { class: "inline-theme-editor", h4 { class: "inline-theme-editor-heading", "Theme" } - // Preset selector. - div { class: "inline-theme-editor-presets", - label { "Start from preset:" } - select { - onchange: { - let onchange = props.onchange.clone(); - let values = props.values.clone(); - move |e: Event| { - let preset_id = e.value(); - if let Some(scheme) = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == preset_id) { - onchange.call(InlineThemeValues { - background: strip_hex_prefix(&scheme.colours.base), - text: strip_hex_prefix(&scheme.colours.text), - primary: strip_hex_prefix(&scheme.colours.primary), - link: strip_hex_prefix(&scheme.colours.link), - light_code_theme: values.light_code_theme.clone(), - dark_code_theme: values.dark_code_theme.clone(), - default_mode: scheme.variant.to_string(), - ..Default::default() - }); - } + // Main theme controls - two columns when wide. + div { class: "inline-theme-editor-main", + // Left column: colours and mode toggle. + div { class: "inline-theme-editor-main-left", + // Colour pickers (2x2 grid). + div { class: "inline-theme-editor-colours", + HexColourInput { + label: Some("Background".to_string()), + value: values().background.clone(), + onchange: move |val| values.write().background = val, } - }, - option { value: "", "Custom" } - for scheme in BUILTIN_COLOUR_SCHEMES.iter() { - option { - value: "{scheme.id}", - "{scheme.name}" + HexColourInput { + label: Some("Text".to_string()), + value: values().text.clone(), + onchange: move |val| values.write().text = val, } - } - } - } - - // Colour pickers. - div { class: "inline-theme-editor-colours", - HexColourInput { - label: Some("Background".to_string()), - value: values.background.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val| { - let mut v = values.clone(); - v.background = val; - onchange.call(v); - } - }, - } - HexColourInput { - label: Some("Text".to_string()), - value: values.text.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val| { - let mut v = values.clone(); - v.text = val; - onchange.call(v); + HexColourInput { + label: Some("Primary".to_string()), + value: values().primary.clone(), + onchange: move |val| values.write().primary = val, } - }, - } - HexColourInput { - label: Some("Primary".to_string()), - value: values.primary.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val| { - let mut v = values.clone(); - v.primary = val; - onchange.call(v); + HexColourInput { + label: Some("Link".to_string()), + value: values().link.clone(), + onchange: move |val| values.write().link = val, } - }, - } - HexColourInput { - label: Some("Link".to_string()), - value: values.link.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val| { - let mut v = values.clone(); - v.link = val; - onchange.call(v); - } - }, - } - } + } - // Code theme selectors (light and dark). - div { class: "inline-theme-editor-code-theme", - label { "Light code theme:" } - select { - value: "{values.light_code_theme}", - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |e: Event| { - let mut v = values.clone(); - v.light_code_theme = e.value(); - onchange.call(v); - } - }, - for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "light") { - option { - value: "{theme.id}", - "{theme.name}" + // Default mode toggle group. + div { class: "inline-theme-editor-mode", + label { "Default mode:" } + ToggleGroup { + horizontal: true, + default_pressed: mode_to_index(&values().default_mode), + on_pressed_change: move |pressed: std::collections::HashSet| { + if let Some(&idx) = pressed.iter().next() { + values.write().default_mode = index_to_mode(idx).to_string(); + } + }, + ToggleItem { index: 0usize, "Auto" } + ToggleItem { index: 1usize, "Light" } + ToggleItem { index: 2usize, "Dark" } } } } - } - div { class: "inline-theme-editor-code-theme", - label { "Dark code theme:" } - select { - value: "{values.dark_code_theme}", - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |e: Event| { - let mut v = values.clone(); - v.dark_code_theme = e.value(); - onchange.call(v); - } - }, - for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "dark") { - option { - value: "{theme.id}", - "{theme.name}" + + // Right column: preset and code theme dropdowns. + div { class: "inline-theme-editor-main-right", + // Preset selector. + div { class: "inline-theme-editor-presets", + label { "Preset:" } + select { + onchange: move |e: Event| { + let preset_id = e.value(); + if let Some(scheme) = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == preset_id) { + let mut v = values.write(); + v.background = strip_hex_prefix(&scheme.colours.base); + v.text = strip_hex_prefix(&scheme.colours.text); + v.primary = strip_hex_prefix(&scheme.colours.primary); + v.link = strip_hex_prefix(&scheme.colours.link); + v.default_mode = scheme.variant.to_string(); + // Clear overrides when selecting preset. + v.light_background = None; + v.light_text = None; + v.light_primary = None; + v.light_link = None; + v.dark_background = None; + v.dark_text = None; + v.dark_primary = None; + v.dark_link = None; + } + }, + option { value: "", "Custom" } + for scheme in BUILTIN_COLOUR_SCHEMES.iter() { + option { + value: "{scheme.id}", + "{scheme.name}" + } + } } } - } - } - // Default mode toggle group. - // Indexes: 0 = auto, 1 = light, 2 = dark - div { class: "inline-theme-editor-mode", - label { "Default mode:" } - ToggleGroup { - horizontal: true, - default_pressed: mode_to_index(&values.default_mode), - on_pressed_change: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |pressed: std::collections::HashSet| { - if let Some(&idx) = pressed.iter().next() { - let mut v = values.clone(); - v.default_mode = index_to_mode(idx).to_string(); - onchange.call(v); + // Code theme selectors. + div { class: "inline-theme-editor-code-theme", + label { "Light code theme:" } + select { + value: "{values().light_code_theme}", + onchange: move |e: Event| { + values.write().light_code_theme = e.value(); + }, + for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "light") { + option { + value: "{theme.id}", + "{theme.name}" + } } } - }, - ToggleItem { index: 0usize, "Auto" } - ToggleItem { index: 1usize, "Light" } - ToggleItem { index: 2usize, "Dark" } + } + div { class: "inline-theme-editor-code-theme", + label { "Dark code theme:" } + select { + value: "{values().dark_code_theme}", + onchange: move |e: Event| { + values.write().dark_code_theme = e.value(); + }, + for theme in BUILTIN_CODE_THEMES.iter().filter(|t| t.variant == "dark") { + option { + value: "{theme.id}", + "{theme.name}" + } + } + } + } } } // Advanced section for per-variant colour customization. - Collapsible { - CollapsibleTrigger { "Advanced colour options" } - CollapsibleContent { - div { class: "inline-theme-editor-advanced-content", - // Light variant colours. - div { class: "inline-theme-editor-variant", - h5 { "Light variant" } - div { class: "inline-theme-editor-colours", - HexColourInput { - label: Some("Background".to_string()), - value: values.light_background.clone().unwrap_or_default(), - placeholder: values.background.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val: String| { - let mut v = values.clone(); - v.light_background = if val.is_empty() { None } else { Some(val) }; - onchange.call(v); - } - }, - } - HexColourInput { - label: Some("Text".to_string()), - value: values.light_text.clone().unwrap_or_default(), - placeholder: values.text.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val: String| { - let mut v = values.clone(); - v.light_text = if val.is_empty() { None } else { Some(val) }; - onchange.call(v); - } - }, - } - HexColourInput { - label: Some("Primary".to_string()), - value: values.light_primary.clone().unwrap_or_default(), - placeholder: values.primary.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val: String| { - let mut v = values.clone(); - v.light_primary = if val.is_empty() { None } else { Some(val) }; - onchange.call(v); - } - }, - } - HexColourInput { - label: Some("Link".to_string()), - value: values.light_link.clone().unwrap_or_default(), - placeholder: values.link.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val: String| { - let mut v = values.clone(); - v.light_link = if val.is_empty() { None } else { Some(val) }; - onchange.call(v); - } - }, + div { class: "{advanced_class}", + Collapsible { + CollapsibleTrigger { "Advanced colour options" } + CollapsibleContent { + div { class: "inline-theme-editor-advanced-content", + // Light variant colours. + div { class: "inline-theme-editor-variant", + h5 { "Light variant" } + div { class: "inline-theme-editor-colours", + HexColourInput { + label: Some("Background".to_string()), + value: values().light_background.clone().unwrap_or_default(), + placeholder: values().background.clone(), + onchange: move |val: String| { + values.write().light_background = if val.is_empty() { None } else { Some(val) }; + }, + } + HexColourInput { + label: Some("Text".to_string()), + value: values().light_text.clone().unwrap_or_default(), + placeholder: values().text.clone(), + onchange: move |val: String| { + values.write().light_text = if val.is_empty() { None } else { Some(val) }; + }, + } + HexColourInput { + label: Some("Primary".to_string()), + value: values().light_primary.clone().unwrap_or_default(), + placeholder: values().primary.clone(), + onchange: move |val: String| { + values.write().light_primary = if val.is_empty() { None } else { Some(val) }; + }, + } + HexColourInput { + label: Some("Link".to_string()), + value: values().light_link.clone().unwrap_or_default(), + placeholder: values().link.clone(), + onchange: move |val: String| { + values.write().light_link = if val.is_empty() { None } else { Some(val) }; + }, + } } } - } - // Dark variant colours. - div { class: "inline-theme-editor-variant", - h5 { "Dark variant" } - div { class: "inline-theme-editor-colours", - HexColourInput { - label: Some("Background".to_string()), - value: values.dark_background.clone().unwrap_or_default(), - placeholder: values.background.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val: String| { - let mut v = values.clone(); - v.dark_background = if val.is_empty() { None } else { Some(val) }; - onchange.call(v); - } - }, - } - HexColourInput { - label: Some("Text".to_string()), - value: values.dark_text.clone().unwrap_or_default(), - placeholder: values.text.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val: String| { - let mut v = values.clone(); - v.dark_text = if val.is_empty() { None } else { Some(val) }; - onchange.call(v); - } - }, - } - HexColourInput { - label: Some("Primary".to_string()), - value: values.dark_primary.clone().unwrap_or_default(), - placeholder: values.primary.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val: String| { - let mut v = values.clone(); - v.dark_primary = if val.is_empty() { None } else { Some(val) }; - onchange.call(v); - } - }, - } - HexColourInput { - label: Some("Link".to_string()), - value: values.dark_link.clone().unwrap_or_default(), - placeholder: values.link.clone(), - onchange: { - let values = props.values.clone(); - let onchange = props.onchange.clone(); - move |val: String| { - let mut v = values.clone(); - v.dark_link = if val.is_empty() { None } else { Some(val) }; - onchange.call(v); - } - }, + // Dark variant colours. + div { class: "inline-theme-editor-variant", + h5 { "Dark variant" } + div { class: "inline-theme-editor-colours", + HexColourInput { + label: Some("Background".to_string()), + value: values().dark_background.clone().unwrap_or_default(), + placeholder: values().background.clone(), + onchange: move |val: String| { + values.write().dark_background = if val.is_empty() { None } else { Some(val) }; + }, + } + HexColourInput { + label: Some("Text".to_string()), + value: values().dark_text.clone().unwrap_or_default(), + placeholder: values().text.clone(), + onchange: move |val: String| { + values.write().dark_text = if val.is_empty() { None } else { Some(val) }; + }, + } + HexColourInput { + label: Some("Primary".to_string()), + value: values().dark_primary.clone().unwrap_or_default(), + placeholder: values().primary.clone(), + onchange: move |val: String| { + values.write().dark_primary = if val.is_empty() { None } else { Some(val) }; + }, + } + HexColourInput { + label: Some("Link".to_string()), + value: values().dark_link.clone().unwrap_or_default(), + placeholder: values().link.clone(), + onchange: move |val: String| { + values.write().dark_link = if val.is_empty() { None } else { Some(val) }; + }, + } } } } @@ -411,11 +336,39 @@ pub fn InlineThemeEditor(props: InlineThemeEditorProps) -> Element { } } - // Link to full editor. - // TODO: Enable once theme page exists - // a { href: "/{ident}/themes", class: "inline-theme-editor-full-link", - // "Edit full theme ->" - // } + // Live preview section. + if props.show_preview { + PreviewSection { values } + } + } + } +} + +/// Preview section with light/dark toggle. +#[component] +fn PreviewSection(values: Signal) -> Element { + let mut preview_dark = use_signal(|| false); + + rsx! { + div { class: "inline-theme-editor-preview", + div { class: "inline-theme-editor-preview-header", + h5 { "Preview" } + ToggleGroup { + horizontal: true, + default_pressed: std::collections::HashSet::from([0usize]), + on_pressed_change: move |pressed: std::collections::HashSet| { + if let Some(&idx) = pressed.iter().next() { + preview_dark.set(idx == 1); + } + }, + ToggleItem { index: 0usize, "Light" } + ToggleItem { index: 1usize, "Dark" } + } + } + ThemePreview { + values, + dark: preview_dark(), + } } } } diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index a13fc29..b1f6b6a 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -9,7 +9,7 @@ pub use app_link::{ }; pub mod css; -pub use css::NotebookCss; +pub use css::{ColourPalette, NotebookCss, ThemePreviewInput, ThemePreviewOutput, generate_theme_preview}; mod entry; #[allow(unused_imports)] @@ -366,6 +366,12 @@ pub use hex_colour_input::HexColourInput; mod inline_theme_editor; pub use inline_theme_editor::{InlineThemeEditor, InlineThemeValues}; +mod theme_preview; +pub use theme_preview::ThemePreview; + +mod theme_editor; +pub use theme_editor::{ThemeEditor, ThemeEditorValues, ColourSchemeValues}; + 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 index 425766d..b0e42b0 100644 --- a/crates/weaver-app/src/components/notebook_editor.rs +++ b/crates/weaver-app/src/components/notebook_editor.rs @@ -25,6 +25,7 @@ pub struct NotebookFormState { pub tags: Vec, pub tags_input: String, pub content_warnings: Vec, + pub content_warnings_input: String, pub rating: Option, pub theme: InlineThemeValues, } @@ -52,6 +53,7 @@ impl NotebookFormState { .as_ref() .map(|cw| cw.iter().map(|s| s.to_string()).collect()) .unwrap_or_default(), + content_warnings_input: String::new(), rating: book.rating.as_ref().map(|r| r.to_string()), theme: theme.unwrap_or_default(), } @@ -76,6 +78,13 @@ pub struct NotebookEditorProps { /// Error message to display. #[props(default)] pub error: Option, + /// Control advanced theme options visibility. + /// None = auto (container query), Some(true) = always show, Some(false) = always hide. + #[props(default)] + pub show_advanced_theme: Option, + /// Show content settings (content warnings, rating). + #[props(default = false)] + pub show_content_settings: bool, } /// Notebook create/edit form. @@ -83,6 +92,9 @@ pub struct NotebookEditorProps { pub fn NotebookEditor(props: NotebookEditorProps) -> Element { let mut state = use_signal(|| props.initial_state.clone().unwrap_or_default()); + // Separate signal for theme editor (InlineThemeEditor writes directly to this). + let theme_values = use_signal(|| state.read().theme.clone()); + let save_label = match props.mode { NotebookEditorMode::Create => "Create", NotebookEditorMode::Edit => "Save", @@ -108,101 +120,182 @@ pub fn NotebookEditor(props: NotebookEditorProps) -> Element { div { class: "notebook-editor", 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." - } - } + // Top section - two columns when wide. + div { class: "notebook-editor-top", + // Left column: title, path. + div { class: "notebook-editor-top-left", + // 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(); + }, + } + } - // 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(); - }, + // 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 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); + // Right column: publish globally, tags. + div { class: "notebook-editor-top-right", + // 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." } } - 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); + + // 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); + }, + "×" } - s.tags_input.clear(); } } - }, + 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; - }, + values: theme_values, + show_advanced: props.show_advanced_theme, + show_preview: true, + } + + // Content settings (optional). + if props.show_content_settings { + div { class: "notebook-editor-content-settings", + h4 { "Content Settings" } + + // Content warnings field. + div { class: "notebook-editor-field", + label { "Content Warnings" } + div { class: "notebook-editor-tags", + for (i, warning) in state.read().content_warnings.iter().enumerate() { + span { + key: "{i}", + class: "notebook-editor-tag notebook-editor-warning", + "{warning}" + button { + class: "notebook-editor-tag-remove", + onclick: move |_| { + state.write().content_warnings.remove(i); + }, + "×" + } + } + } + input { + r#type: "text", + class: "notebook-editor-tags-input", + value: "{state.read().content_warnings_input}", + placeholder: "Add warning...", + oninput: move |e| { + state.write().content_warnings_input = e.value(); + }, + onkeydown: move |e| { + if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { + e.prevent_default(); + let warning = state.read().content_warnings_input.trim().to_string(); + if !warning.is_empty() { + let mut s = state.write(); + if !s.content_warnings.contains(&warning) { + s.content_warnings.push(warning); + } + s.content_warnings_input.clear(); + } + } + }, + } + } + span { class: "notebook-editor-hint", + "Add content warnings for sensitive material (e.g., violence, adult themes)." + } + } + + // Rating field. + div { class: "notebook-editor-field", + label { "Content Rating" } + select { + value: state.read().rating.clone().unwrap_or_default(), + onchange: move |e: Event| { + let val = e.value(); + state.write().rating = if val.is_empty() { None } else { Some(val) }; + }, + option { value: "", "None" } + option { value: "general", "General" } + option { value: "teen", "Teen" } + option { value: "mature", "Mature" } + option { value: "adult", "Adult" } + } + span { class: "notebook-editor-hint", + "Age-appropriateness rating for your notebook's content." + } + } + } } // Error display. @@ -215,7 +308,9 @@ pub fn NotebookEditor(props: NotebookEditorProps) -> Element { Button { variant: ButtonVariant::Primary, onclick: move |_| { - props.on_save.call(state.read().clone()); + let mut form_state = state.read().clone(); + form_state.theme = theme_values(); + props.on_save.call(form_state); }, disabled: !can_save || props.saving, if props.saving { "Saving..." } else { "{save_label}" } diff --git a/crates/weaver-app/src/components/theme_editor.rs b/crates/weaver-app/src/components/theme_editor.rs new file mode 100644 index 0000000..d02acca --- /dev/null +++ b/crates/weaver-app/src/components/theme_editor.rs @@ -0,0 +1,608 @@ +//! Full theme editor component with all 16 colours, fonts, spacing. + +use dioxus::prelude::*; +use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES}; + +use crate::components::HexColourInput; +use crate::components::button::{Button, ButtonVariant}; +use crate::components::inline_theme_editor::InlineThemeValues; +use crate::components::select::{Select, SelectList, SelectOption, SelectTrigger, SelectValue}; +use crate::components::theme_preview::ThemePreview; +use crate::components::toggle_group::{ToggleGroup, ToggleItem}; + +const THEME_EDITOR_CSS: Asset = asset!("/assets/styling/theme-editor.css"); + +/// Full theme values for the theme editor. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ThemeEditorValues { + // Light scheme colours (all 16). + pub light: ColourSchemeValues, + // Dark scheme colours (all 16). + pub dark: ColourSchemeValues, + // Fonts. + pub font_body: String, + pub font_heading: String, + pub font_mono: String, + // Spacing. + pub spacing_base: String, + pub spacing_line_height: String, + pub spacing_scale: String, + // Code themes. + pub light_code_theme: String, + pub dark_code_theme: String, + // Default mode. + pub default_mode: String, +} + +/// All 16 colour values for a single scheme. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ColourSchemeValues { + pub base: String, + pub surface: String, + pub overlay: String, + pub text: String, + pub muted: String, + pub subtle: String, + pub emphasis: String, + pub primary: String, + pub secondary: String, + pub tertiary: String, + pub error: String, + pub warning: String, + pub success: String, + pub border: String, + pub link: String, + pub highlight: String, +} + +impl ThemeEditorValues { + /// Convert to InlineThemeValues for preview (uses primary 4 colours from light scheme). + pub fn to_inline_values(&self) -> InlineThemeValues { + InlineThemeValues { + background: self.light.base.clone(), + text: self.light.text.clone(), + primary: self.light.primary.clone(), + link: self.light.link.clone(), + light_background: Some(self.light.base.clone()), + light_text: Some(self.light.text.clone()), + light_primary: Some(self.light.primary.clone()), + light_link: Some(self.light.link.clone()), + dark_background: Some(self.dark.base.clone()), + dark_text: Some(self.dark.text.clone()), + dark_primary: Some(self.dark.primary.clone()), + dark_link: Some(self.dark.link.clone()), + light_code_theme: self.light_code_theme.clone(), + dark_code_theme: self.dark_code_theme.clone(), + default_mode: self.default_mode.clone(), + } + } + + /// Create from a preset colour scheme. + pub fn from_preset(scheme_id: &str) -> Option { + let scheme = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == scheme_id)?; + let colours = &scheme.colours; + + let scheme_values = ColourSchemeValues { + base: strip_hex_prefix(&colours.base), + surface: strip_hex_prefix(&colours.surface), + overlay: strip_hex_prefix(&colours.overlay), + text: strip_hex_prefix(&colours.text), + muted: strip_hex_prefix(&colours.muted), + subtle: strip_hex_prefix(&colours.subtle), + emphasis: strip_hex_prefix(&colours.emphasis), + primary: strip_hex_prefix(&colours.primary), + secondary: strip_hex_prefix(&colours.secondary), + tertiary: strip_hex_prefix(&colours.tertiary), + error: strip_hex_prefix(&colours.error), + warning: strip_hex_prefix(&colours.warning), + success: strip_hex_prefix(&colours.success), + border: strip_hex_prefix(&colours.border), + link: strip_hex_prefix(&colours.link), + highlight: strip_hex_prefix(&colours.highlight), + }; + + // Find counterpart scheme for dark/light. + let is_dark = scheme.variant == "dark"; + let counterpart_id = if is_dark { + "rose-pine-dawn" + } else { + "rose-pine" + }; + let counterpart = BUILTIN_COLOUR_SCHEMES + .iter() + .find(|s| s.id == counterpart_id); + + let counterpart_values = counterpart + .map(|c| { + let colours = &c.colours; + ColourSchemeValues { + base: strip_hex_prefix(&colours.base), + surface: strip_hex_prefix(&colours.surface), + overlay: strip_hex_prefix(&colours.overlay), + text: strip_hex_prefix(&colours.text), + muted: strip_hex_prefix(&colours.muted), + subtle: strip_hex_prefix(&colours.subtle), + emphasis: strip_hex_prefix(&colours.emphasis), + primary: strip_hex_prefix(&colours.primary), + secondary: strip_hex_prefix(&colours.secondary), + tertiary: strip_hex_prefix(&colours.tertiary), + error: strip_hex_prefix(&colours.error), + warning: strip_hex_prefix(&colours.warning), + success: strip_hex_prefix(&colours.success), + border: strip_hex_prefix(&colours.border), + link: strip_hex_prefix(&colours.link), + highlight: strip_hex_prefix(&colours.highlight), + } + }) + .unwrap_or_default(); + + let (light, dark) = if is_dark { + (counterpart_values, scheme_values) + } else { + (scheme_values, counterpart_values) + }; + + Some(Self { + light, + dark, + font_body: String::new(), + font_heading: String::new(), + font_mono: String::new(), + spacing_base: "16".to_string(), + spacing_line_height: "1.6".to_string(), + spacing_scale: "1.25".to_string(), + light_code_theme: "rose-pine-dawn".to_string(), + dark_code_theme: "rose-pine".to_string(), + default_mode: scheme.variant.to_string(), + }) + } +} + +fn strip_hex_prefix(s: &str) -> String { + s.trim_start_matches('#') + .trim_start_matches("0x") + .trim_start_matches("0X") + .to_uppercase() +} + +fn mode_to_index(mode: &str) -> std::collections::HashSet { + let idx = match mode { + "light" => 1, + "dark" => 2, + _ => 0, + }; + std::collections::HashSet::from([idx]) +} + +fn index_to_mode(idx: usize) -> &'static str { + match idx { + 1 => "light", + 2 => "dark", + _ => "auto", + } +} + +/// Props for ThemeEditor. +#[derive(Props, Clone, PartialEq)] +pub struct ThemeEditorProps { + /// Current theme values (signal for reactivity). + pub values: Signal, + /// Callback on save. + pub on_save: EventHandler, + /// Callback on cancel. + pub on_cancel: EventHandler<()>, + /// Whether save is in progress. + #[props(default = false)] + pub saving: bool, +} + +/// Full theme editor with all 16 colours, fonts, spacing. +#[component] +pub fn ThemeEditor(props: ThemeEditorProps) -> Element { + let mut values = props.values; + + // Preview values derived from editor values. + let mut preview_values = use_signal(|| values().to_inline_values()); + + // Sync preview when values change. + use_effect(move || { + let v = values(); + preview_values.set(v.to_inline_values()); + }); + + // Which variant is being edited (for preview). + let mut editing_dark = use_signal(|| false); + + // Derived signals for light/dark schemes. + let light_scheme = use_memo(move || values().light.clone()); + let dark_scheme = use_memo(move || values().dark.clone()); + + rsx! { + document::Stylesheet { href: THEME_EDITOR_CSS } + + div { class: "theme-editor-page", + // Left column: controls. + div { class: "theme-editor-controls", + // Mode toggle. + ModeSection { values } + + // Fonts section. + FontsSection { values } + + // Spacing section. + SpacingSection { values } + + // Code themes. + CodeThemesSection { values } + + // Light scheme colours. + ColourSchemeSection { + title: "Light scheme", + variant: "light", + scheme: light_scheme, + on_change: move |new_scheme: ColourSchemeValues| { + values.write().light = new_scheme; + }, + on_focus: move |_| editing_dark.set(false), + } + + // Dark scheme colours. + ColourSchemeSection { + title: "Dark scheme", + variant: "dark", + scheme: dark_scheme, + on_change: move |new_scheme: ColourSchemeValues| { + values.write().dark = new_scheme; + }, + on_focus: move |_| editing_dark.set(true), + } + + // Actions. + div { class: "theme-editor-actions", + Button { + variant: ButtonVariant::Primary, + onclick: move |_| { + props.on_save.call(values()); + }, + disabled: props.saving, + if props.saving { "Saving..." } else { "Save" } + } + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| { + props.on_cancel.call(()); + }, + disabled: props.saving, + "Cancel" + } + } + } + + // Right column: preview. + div { class: "theme-editor-preview", + div { class: "theme-editor-preview-header", + h3 { "Preview" } + ToggleGroup { + horizontal: true, + default_pressed: std::collections::HashSet::from([0usize]), + on_pressed_change: move |pressed: std::collections::HashSet| { + if let Some(&idx) = pressed.iter().next() { + editing_dark.set(idx == 1); + } + }, + ToggleItem { index: 0usize, "Light" } + ToggleItem { index: 1usize, "Dark" } + } + } + ThemePreview { + values: preview_values, + dark: editing_dark(), + } + } + } + } +} + +/// Colour scheme section with all 16 colours and its own preset selector. +#[component] +fn ColourSchemeSection( + title: &'static str, + variant: &'static str, + scheme: ReadSignal, + on_change: EventHandler, + on_focus: EventHandler, +) -> Element { + // Helper to create colour input with change handler. + let colour_input = + move |label: &'static str, value: String, setter: fn(&mut ColourSchemeValues, String)| { + let on_change = on_change.clone(); + let on_focus = on_focus.clone(); + let scheme = scheme.clone(); + rsx! { + HexColourInput { + label: Some(label.to_string()), + value: value, + onchange: move |val: String| { + let mut new_scheme = scheme(); + setter(&mut new_scheme, val); + on_change.call(new_scheme); + }, + onfocus: move |e| on_focus.call(e), + } + } + }; + + let s = scheme(); + + // Filter presets by variant. + let presets: Vec<_> = BUILTIN_COLOUR_SCHEMES + .iter() + .filter(|p| p.variant == variant) + .collect(); + + rsx! { + div { class: "theme-editor-section", + div { class: "theme-editor-section-header", + h3 { "{title}" } + Select:: { + placeholder: "Custom", + on_value_change: move |val: Option| { + let Some(val) = val else { return }; + let Some(preset) = BUILTIN_COLOUR_SCHEMES.iter().find(|p| p.id == val) else { return }; + let colours = &preset.colours; + on_change.call(ColourSchemeValues { + base: strip_hex_prefix(&colours.base), + surface: strip_hex_prefix(&colours.surface), + overlay: strip_hex_prefix(&colours.overlay), + text: strip_hex_prefix(&colours.text), + muted: strip_hex_prefix(&colours.muted), + subtle: strip_hex_prefix(&colours.subtle), + emphasis: strip_hex_prefix(&colours.emphasis), + primary: strip_hex_prefix(&colours.primary), + secondary: strip_hex_prefix(&colours.secondary), + tertiary: strip_hex_prefix(&colours.tertiary), + error: strip_hex_prefix(&colours.error), + warning: strip_hex_prefix(&colours.warning), + success: strip_hex_prefix(&colours.success), + border: strip_hex_prefix(&colours.border), + link: strip_hex_prefix(&colours.link), + highlight: strip_hex_prefix(&colours.highlight), + }); + }, + SelectTrigger { + SelectValue {} + } + SelectList { + for (idx, preset) in presets.iter().enumerate() { + SelectOption:: { + index: idx, + value: preset.id.to_string(), + text_value: preset.name.to_string(), + "{preset.name}" + } + } + } + } + } + + // Background colours. + div { class: "theme-editor-colour-group", + span { class: "theme-editor-colour-group-label", "Background" } + div { class: "theme-editor-colour-group-items", + {colour_input("Base", s.base.clone(), |s, v| s.base = v)} + {colour_input("Surface", s.surface.clone(), |s, v| s.surface = v)} + {colour_input("Overlay", s.overlay.clone(), |s, v| s.overlay = v)} + } + } + + // Text colours. + div { class: "theme-editor-colour-group", + span { class: "theme-editor-colour-group-label", "Text" } + div { class: "theme-editor-colour-group-items", + {colour_input("Text", s.text.clone(), |s, v| s.text = v)} + {colour_input("Muted", s.muted.clone(), |s, v| s.muted = v)} + {colour_input("Subtle", s.subtle.clone(), |s, v| s.subtle = v)} + {colour_input("Emphasis", s.emphasis.clone(), |s, v| s.emphasis = v)} + } + } + + // Accent colours. + div { class: "theme-editor-colour-group", + span { class: "theme-editor-colour-group-label", "Accents" } + div { class: "theme-editor-colour-group-items", + {colour_input("Primary", s.primary.clone(), |s, v| s.primary = v)} + {colour_input("Secondary", s.secondary.clone(), |s, v| s.secondary = v)} + {colour_input("Tertiary", s.tertiary.clone(), |s, v| s.tertiary = v)} + {colour_input("Link", s.link.clone(), |s, v| s.link = v)} + } + } + + // Status colours. + div { class: "theme-editor-colour-group", + span { class: "theme-editor-colour-group-label", "Status" } + div { class: "theme-editor-colour-group-items", + {colour_input("Error", s.error.clone(), |s, v| s.error = v)} + {colour_input("Warning", s.warning.clone(), |s, v| s.warning = v)} + {colour_input("Success", s.success.clone(), |s, v| s.success = v)} + } + } + + // UI colours. + div { class: "theme-editor-colour-group", + span { class: "theme-editor-colour-group-label", "UI" } + div { class: "theme-editor-colour-group-items", + {colour_input("Border", s.border.clone(), |s, v| s.border = v)} + {colour_input("Highlight", s.highlight.clone(), |s, v| s.highlight = v)} + } + } + } + } +} + +/// Fonts section. +#[component] +fn FontsSection(values: Signal) -> Element { + rsx! { + div { class: "theme-editor-section", + h3 { "Fonts" } + div { class: "theme-editor-fonts", + div { class: "theme-editor-font-field", + label { "Body" } + input { + r#type: "text", + value: "{values().font_body}", + placeholder: "IBM Plex Serif", + oninput: move |e| values.write().font_body = e.value(), + } + } + div { class: "theme-editor-font-field", + label { "Heading" } + input { + r#type: "text", + value: "{values().font_heading}", + placeholder: "IBM Plex Sans", + oninput: move |e| values.write().font_heading = e.value(), + } + } + div { class: "theme-editor-font-field", + label { "Monospace" } + input { + r#type: "text", + value: "{values().font_mono}", + placeholder: "IBM Plex Mono", + oninput: move |e| values.write().font_mono = e.value(), + } + } + } + } + } +} + +/// Spacing section. +#[component] +fn SpacingSection(values: Signal) -> Element { + rsx! { + div { class: "theme-editor-section", + h3 { "Spacing" } + div { class: "theme-editor-spacing", + div { class: "theme-editor-spacing-field", + label { "Base size (px)" } + input { + r#type: "number", + value: "{values().spacing_base}", + oninput: move |e| values.write().spacing_base = e.value(), + } + } + div { class: "theme-editor-spacing-field", + label { "Line height" } + input { + r#type: "text", + value: "{values().spacing_line_height}", + oninput: move |e| values.write().spacing_line_height = e.value(), + } + } + div { class: "theme-editor-spacing-field", + label { "Scale" } + input { + r#type: "text", + value: "{values().spacing_scale}", + oninput: move |e| values.write().spacing_scale = e.value(), + } + } + } + } + } +} + +/// Code themes section. +#[component] +fn CodeThemesSection(values: Signal) -> Element { + let light_themes: Vec<_> = BUILTIN_CODE_THEMES + .iter() + .filter(|t| t.variant == "light") + .collect(); + let dark_themes: Vec<_> = BUILTIN_CODE_THEMES + .iter() + .filter(|t| t.variant == "dark") + .collect(); + + rsx! { + div { class: "theme-editor-section", + h3 { "Code themes" } + div { class: "theme-editor-code-themes", + div { class: "theme-editor-code-theme", + label { "Light" } + Select:: { + value: Some(values().light_code_theme.clone()), + on_value_change: move |val: Option| { + if let Some(val) = val { + values.write().light_code_theme = val; + } + }, + SelectTrigger { + SelectValue {} + } + SelectList { + for (idx, theme) in light_themes.iter().enumerate() { + SelectOption:: { + index: idx, + value: theme.id.to_string(), + text_value: theme.name.to_string(), + "{theme.name}" + } + } + } + } + } + div { class: "theme-editor-code-theme", + label { "Dark" } + Select:: { + value: Some(values().dark_code_theme.clone()), + on_value_change: move |val: Option| { + if let Some(val) = val { + values.write().dark_code_theme = val; + } + }, + SelectTrigger { + SelectValue {} + } + SelectList { + for (idx, theme) in dark_themes.iter().enumerate() { + SelectOption:: { + index: idx, + value: theme.id.to_string(), + text_value: theme.name.to_string(), + "{theme.name}" + } + } + } + } + } + } + } + } +} + +/// Mode section. +#[component] +fn ModeSection(values: Signal) -> Element { + rsx! { + div { class: "theme-editor-section", + div { class: "theme-editor-mode", + label { "Default mode:" } + ToggleGroup { + horizontal: true, + default_pressed: mode_to_index(&values().default_mode), + on_pressed_change: move |pressed: std::collections::HashSet| { + if let Some(&idx) = pressed.iter().next() { + values.write().default_mode = index_to_mode(idx).to_string(); + } + }, + ToggleItem { index: 0usize, "Auto" } + ToggleItem { index: 1usize, "Light" } + ToggleItem { index: 2usize, "Dark" } + } + } + } + } +} diff --git a/crates/weaver-app/src/components/theme_preview.rs b/crates/weaver-app/src/components/theme_preview.rs new file mode 100644 index 0000000..1a780db --- /dev/null +++ b/crates/weaver-app/src/components/theme_preview.rs @@ -0,0 +1,149 @@ +//! Theme preview component with sample markdown rendering. + +use dioxus::prelude::*; + +use crate::components::css::{ThemePreviewInput, generate_theme_preview}; +use crate::components::inline_theme_editor::InlineThemeValues; + +const NOTEBOOK_DEFAULTS_CSS: Asset = asset!("/assets/styling/notebook-defaults.css"); + +/// Sample markdown to render for theme preview. +const SAMPLE_MARKDOWN: &str = r#"# Heading 1 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +## Heading 2 + +Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Here's a [link to somewhere](#) and some **bold text** with *italics*. + +### Heading 3 + +> A blockquote for emphasis. This tests the border and muted text colours. + +Here's a list of items: + +- First item with some text +- Second item with `inline code` +- Third item + +And some numbered steps: + +1. Do the first thing +2. Then the second +3. Finally the third + +```rust +fn main() { + // A code block to test syntax highlighting + let message = "Hello, world!"; + println!("{}", message); +} +``` + +--- + +That's the end of the preview. +"#; + +/// Render markdown to HTML using the full pipeline with syntax highlighting. +fn render_markdown(markdown: &str) -> String { + use markdown_weaver::Parser; + use weaver_renderer::atproto::ClientWriter; + use weaver_renderer::default_md_options; + + let parser = Parser::new_ext(markdown, default_md_options()).into_offset_iter(); + let mut html = String::new(); + ClientWriter::<_, _, ()>::new(parser, &mut html, markdown) + .run() + .ok(); + html +} + +/// Props for ThemePreview with signal for reactivity. +#[derive(Props, Clone, PartialEq)] +pub struct ThemePreviewProps { + /// Theme values to preview (signal for reactivity). + pub values: Signal, + /// Whether to show dark variant (false = light). + #[props(default = false)] + pub dark: bool, +} + +/// Theme preview component that renders sample markdown with theme applied. +#[component] +pub fn ThemePreview(props: ThemePreviewProps) -> Element { + let dark = props.dark; + let values = props.values; + + let mut preview_resource = use_resource(move || { + let values = values(); + async move { + // Skip if background is empty (invalid). + if values.background.is_empty() { + return Err(ServerFnError::new("No theme values set")); + } + + let input = ThemePreviewInput { + background: values.background.clone(), + text: values.text.clone(), + primary: values.primary.clone(), + link: values.link.clone(), + light_background: values.light_background.clone(), + light_text: values.light_text.clone(), + light_primary: values.light_primary.clone(), + light_link: values.light_link.clone(), + dark_background: values.dark_background.clone(), + dark_text: values.dark_text.clone(), + dark_primary: values.dark_primary.clone(), + dark_link: values.dark_link.clone(), + light_code_theme: values.light_code_theme.clone(), + dark_code_theme: values.dark_code_theme.clone(), + }; + generate_theme_preview(input).await + } + }); + + // Restart resource when values change. + use_effect(move || { + let _ = values(); + preview_resource.restart(); + }); + + let rendered_html = render_markdown(SAMPLE_MARKDOWN); + + match preview_resource() { + Some(Ok(output)) => { + let palette = if dark { &output.dark_palette } else { &output.light_palette }; + let css_vars = palette.to_css_vars(); + + // Scoped CSS: variables -> notebook defaults -> syntax highlighting + let scoped_css = format!(".theme-preview {{ {} }}", css_vars); + + rsx! { + // 1. CSS variables scoped to .theme-preview + style { dangerous_inner_html: "{scoped_css}" } + + // 2. Notebook content styles (uses the variables) + document::Stylesheet { href: NOTEBOOK_DEFAULTS_CSS } + + // 3. Syntax highlighting CSS + style { dangerous_inner_html: "{output.syntax_css}" } + + div { + class: "theme-preview notebook-content", + dangerous_inner_html: "{rendered_html}" + } + } + } + Some(Err(e)) => rsx! { + div { class: "theme-preview theme-preview--error", + "Failed to generate theme preview: {e}" + } + }, + None => rsx! { + div { class: "theme-preview theme-preview--loading", + "Loading preview..." + } + }, + } +} diff --git a/crates/weaver-app/src/custom_domain_app.rs b/crates/weaver-app/src/custom_domain_app.rs index e8fea3a..0504800 100644 --- a/crates/weaver-app/src/custom_domain_app.rs +++ b/crates/weaver-app/src/custom_domain_app.rs @@ -80,7 +80,7 @@ fn Index() -> Element { } #[component] -fn EntryByRkey(rkey: ReadOnlySignal) -> Element { +fn EntryByRkey(rkey: ReadSignal) -> Element { let ctx = use_context::(); rsx! { @@ -93,7 +93,7 @@ fn EntryByRkey(rkey: ReadOnlySignal) -> Element { } #[component] -fn EntryEdit(rkey: ReadOnlySignal) -> Element { +fn EntryEdit(rkey: ReadSignal) -> Element { let ctx = use_context::(); rsx! { @@ -113,7 +113,7 @@ fn Profile(ident: AtIdentifier<'static>) -> Element { } #[component] -fn PathPage(segments: ReadOnlySignal>) -> Element { +fn PathPage(segments: ReadSignal>) -> Element { let ctx = use_context::(); let ident = use_memo(move || ctx.owner.clone()); diff --git a/crates/weaver-app/src/lib.rs b/crates/weaver-app/src/lib.rs index f2e8693..c0b9494 100644 --- a/crates/weaver-app/src/lib.rs +++ b/crates/weaver-app/src/lib.rs @@ -48,9 +48,9 @@ use config::{Config, OAuthConfig}; use views::{ AboutPage, Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, LeafletEntry, LeafletEntryNsid, Navbar, NewDraft, Notebook, NotebookEntryByRkey, NotebookEntryEdit, - NotebookIndex, NotebookPage, PcktEntry, PcktEntryBlogNsid, PcktEntryNsid, PrivacyPage, - RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit, StandaloneEntryNsid, TermsPage, - WhiteWindEntry, WhiteWindEntryNsid, + NotebookIndex, NotebookPage, NotebookSettings, PcktEntry, PcktEntryBlogNsid, PcktEntryNsid, + PrivacyPage, RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit, + StandaloneEntryNsid, TermsPage, WhiteWindEntry, WhiteWindEntryNsid, }; #[derive(Debug, Clone, Routable, PartialEq)] @@ -118,6 +118,9 @@ pub enum Route { #[layout(Notebook)] #[route("/")] NotebookIndex { ident: AtIdentifier<'static>, book_title: SmolStr }, + // Settings must come before /:title to avoid capture + #[route("/settings")] + NotebookSettings { ident: AtIdentifier<'static>, book_title: SmolStr }, #[route("/:title")] EntryPage { ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr }, // Entry by rkey (canonical path) diff --git a/crates/weaver-app/src/views/footer.rs b/crates/weaver-app/src/views/footer.rs index a8daa60..5e79c35 100644 --- a/crates/weaver-app/src/views/footer.rs +++ b/crates/weaver-app/src/views/footer.rs @@ -37,6 +37,7 @@ pub fn should_show_full_footer(route: &Route) -> bool { | Route::StandaloneEntryNsid { ident, .. } | Route::StandaloneEntryEdit { ident, .. } | Route::NotebookIndex { ident, .. } + | Route::NotebookSettings { ident, .. } | Route::EntryPage { ident, .. } | Route::NotebookEntryByRkey { ident, .. } | Route::NotebookEntryEdit { ident, .. } => is_owner_ident(ident), diff --git a/crates/weaver-app/src/views/mod.rs b/crates/weaver-app/src/views/mod.rs index c75d897..2b17bce 100644 --- a/crates/weaver-app/src/views/mod.rs +++ b/crates/weaver-app/src/views/mod.rs @@ -52,3 +52,6 @@ pub use external::{ mod subdomain_navbar; pub use subdomain_navbar::{SubdomainErrorLayout, SubdomainNavbar}; + +mod notebook_settings; +pub use notebook_settings::NotebookSettings; diff --git a/crates/weaver-app/src/views/notebook_settings.rs b/crates/weaver-app/src/views/notebook_settings.rs new file mode 100644 index 0000000..1689202 --- /dev/null +++ b/crates/weaver-app/src/views/notebook_settings.rs @@ -0,0 +1,960 @@ +//! Notebook settings view with full theme editor. + +use dioxus::prelude::*; +use jacquard::client::AgentSessionExt; +use jacquard::common::from_data; +use jacquard::smol_str::SmolStr; +use jacquard::types::aturi::AtUri; +use jacquard::types::ident::AtIdentifier; +use jacquard::types::string::Datetime; +use jacquard::{CowStr, IntoStatic}; +use weaver_api::com_atproto::repo::strong_ref::StrongRef; +use weaver_api::sh_weaver::notebook::book::Book; +use weaver_api::sh_weaver::notebook::colour_scheme::ColourScheme; +use weaver_api::sh_weaver::notebook::theme::{ + Theme, ThemeDarkCodeTheme, ThemeFonts, ThemeLightCodeTheme, ThemeSpacing, +}; + +use crate::Route; +use crate::auth::AuthState; +use crate::components::button::{Button, ButtonVariant}; +use crate::components::notebook::{delete_publication, sync_publication}; +use crate::components::{ColourSchemeValues, ThemeEditor, ThemeEditorValues}; +use crate::data; +use crate::fetch::Fetcher; + +const NOTEBOOK_SETTINGS_CSS: Asset = asset!("/assets/styling/notebook-settings.css"); + +/// Form state for notebook settings. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct NotebookSettingsState { + 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: ThemeEditorValues, +} + +/// Props for NotebookSettings view. +#[derive(Props, Clone, PartialEq)] +pub struct NotebookSettingsProps { + pub ident: ReadSignal>, + pub book_title: ReadSignal, +} + +/// Notebook settings page with full theme editor. +#[component] +pub fn NotebookSettings(props: NotebookSettingsProps) -> Element { + // Load notebook data. + let (notebook_result, notebook_data) = data::use_notebook(props.ident, props.book_title); + + #[cfg(feature = "fullstack-server")] + let _ = notebook_result?; + + let auth_state = use_context::>(); + let fetcher = use_context::(); + let navigator = use_navigator(); + + // Form state - editable copy of initial values. + let mut state = use_signal(NotebookSettingsState::default); + let mut state_initialized = use_signal(|| false); + let mut theme_values = use_signal(|| ThemeEditorValues::from_preset("rose-pine").unwrap_or_default()); + let mut theme_initialized = use_signal(|| false); + let mut saving = use_signal(|| false); + let mut error = use_signal(|| None::); + + // Active section for navigation. + let mut active_section = use_signal(|| "general".to_string()); + + // Check ownership. + let current_did = auth_state.read().did.clone(); + let ident_val = props.ident.read().clone(); + let is_owner = match (¤t_did, &ident_val) { + (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did, + _ => false, + }; + + // Derive notebook URI and book from loaded data. + let notebook_uri = use_memo(move || { + let data = notebook_data()?; + let (notebook_view, _) = &data; + Some(notebook_view.uri.clone().into_static()) + }); + + let current_book = use_memo(move || { + let data = notebook_data()?; + let (notebook_view, _) = &data; + let book: Book<'_> = from_data(¬ebook_view.record).ok()?; + Some(book.into_static()) + }); + + // Derive initial form state from notebook data. + let initial_form_state = use_memo(move || { + let book = current_book()?; + Some(NotebookSettingsState { + title: book + .title + .as_ref() + .map(|t| t.as_ref().to_string()) + .unwrap_or_default(), + path: book + .path + .as_ref() + .map(|p| p.as_ref().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.as_ref().to_string()).collect()) + .unwrap_or_default(), + tags_input: String::new(), + content_warnings: book + .content_warnings + .as_ref() + .map(|cw| cw.iter().map(|s| s.as_ref().to_string()).collect()) + .unwrap_or_default(), + rating: book.rating.as_ref().map(|r| r.as_ref().to_string()), + theme: ThemeEditorValues::default(), + }) + }); + + // Load theme values from theme ref. + let theme_fetcher = fetcher.clone(); + let theme_resource = use_resource(move || { + let theme_ref = current_book() + .and_then(|b| b.theme.clone()) + .map(|t| t.into_static()); + let fetcher = theme_fetcher.clone(); + async move { + let Some(theme_ref) = theme_ref else { + return None; + }; + load_full_theme_values(&fetcher, &theme_ref).await.ok() + } + }); + + // Initialize editable state from loaded data (once). + use_effect(move || { + if !state_initialized() { + if let Some(form_state) = initial_form_state() { + state.set(form_state); + state_initialized.set(true); + } + } + if !theme_initialized() { + if let Some(Some(theme_vals)) = theme_resource.read().as_ref() { + theme_values.set(theme_vals.clone()); + theme_initialized.set(true); + } + } + }); + + if !is_owner { + return rsx! { + div { class: "notebook-settings-unauthorized", + h1 { "Unauthorized" } + p { "You don't have permission to edit this notebook's settings." } + } + }; + } + + // Save general settings handler. + let save_fetcher = fetcher.clone(); + let handle_save = move |_| { + let fetcher = save_fetcher.clone(); + let uri = notebook_uri(); + let book = current_book(); + + spawn(async move { + let Some(uri) = uri else { + error.set(Some("Notebook not loaded".to_string())); + return; + }; + let Some(existing_book) = book else { + error.set(Some("Notebook not loaded".to_string())); + return; + }; + + saving.set(true); + error.set(None); + + let form = state(); + let now = Datetime::now(); + + let tags: Option>> = if form.tags.is_empty() { + None + } else { + Some(form.tags.iter().map(|s| CowStr::from(s.clone())).collect()) + }; + + use weaver_api::sh_weaver::notebook::{ContentRating, ContentWarning}; + + let content_warnings: Option>> = + if form.content_warnings.is_empty() { + None + } else { + Some( + form.content_warnings + .iter() + .map(|s| ContentWarning::from(s.clone())) + .collect(), + ) + }; + + let path: CowStr<'static> = form.path.clone().into(); + let title: CowStr<'static> = form.title.clone().into(); + let publish_global = form.publish_global; + let rating: Option> = + form.rating.clone().map(|r| ContentRating::from(r)); + + let client = fetcher.get_client(); + match client + .update_record::(&uri, |book| { + book.title = Some(title.clone()); + if !path.is_empty() { + book.path = Some(path.clone()); + } + book.publish_global = Some(publish_global); + book.tags = tags.clone(); + book.content_warnings = content_warnings.clone(); + book.rating = rating.clone(); + book.updated_at = Some(now.clone()); + }) + .await + { + Ok(_) => { + // Sync or delete publication based on publish_global. + let theme_vals = crate::components::InlineThemeValues::default(); + if publish_global { + if let Err(e) = + sync_publication(&fetcher, &uri, &title, &path, &theme_vals).await + { + tracing::warn!("Failed to sync publication: {:?}", e); + } + } else if let Err(e) = delete_publication(&fetcher, &uri).await { + tracing::warn!("Failed to delete publication: {:?}", e); + } + saving.set(false); + } + Err(e) => { + error.set(Some(format!("Failed to save: {:?}", e))); + saving.set(false); + } + } + }); + }; + + rsx! { + document::Stylesheet { href: NOTEBOOK_SETTINGS_CSS } + + div { class: "notebook-settings", + // Sidebar navigation. + nav { class: "notebook-settings-nav", + button { + class: if active_section() == "general" { "active" } else { "" }, + onclick: move |_| active_section.set("general".to_string()), + "General" + } + button { + class: if active_section() == "theme" { "active" } else { "" }, + onclick: move |_| active_section.set("theme".to_string()), + "Theme" + } + button { + class: if active_section() == "collaborators" { "active" } else { "" }, + onclick: move |_| active_section.set("collaborators".to_string()), + "Collaborators" + } + button { + class: if active_section() == "danger" { "active" } else { "" }, + onclick: move |_| active_section.set("danger".to_string()), + "Danger Zone" + } + } + + // Content area. + div { class: "notebook-settings-content", + match active_section().as_str() { + "general" => rsx! { + GeneralSection { + state: state, + saving: saving(), + error: error(), + on_save: handle_save, + } + }, + "theme" => rsx! { + ThemeSection { + values: theme_values, + saving: saving(), + on_save: { + let fetcher = fetcher.clone(); + move |values: ThemeEditorValues| { + let fetcher = fetcher.clone(); + let uri = notebook_uri(); + let book = current_book(); + let values = values.clone(); + + spawn(async move { + let Some(uri) = uri else { + error.set(Some("Notebook not loaded".to_string())); + return; + }; + let Some(existing_book) = book else { + error.set(Some("Notebook not loaded".to_string())); + return; + }; + + saving.set(true); + error.set(None); + + // Sync theme records. + match sync_full_theme(&fetcher, existing_book.theme.as_ref(), &values).await { + Ok(theme_result) => { + // Update book with new theme ref. + let theme_ref = StrongRef::new() + .uri(theme_result.theme_uri) + .cid(theme_result.theme_cid) + .build(); + + let client = fetcher.get_client(); + let now = Datetime::now(); + match client + .update_record::(&uri, |book| { + book.theme = Some(theme_ref.clone()); + book.updated_at = Some(now.clone()); + }) + .await + { + Ok(_) => { + theme_values.set(values); + saving.set(false); + } + Err(e) => { + error.set(Some(format!("Failed to update book: {:?}", e))); + saving.set(false); + } + } + } + Err(e) => { + error.set(Some(format!("Failed to sync theme: {:?}", e))); + saving.set(false); + } + } + }); + } + }, + } + }, + "collaborators" => rsx! { + CollaboratorsSection {} + }, + "danger" => rsx! { + DangerSection { + notebook_uri: notebook_uri(), + on_deleted: { + let ident = ident_val.clone(); + move |_| { + navigator.push(Route::RepositoryIndex { ident: ident.clone() }); + } + }, + } + }, + _ => rsx! { div { "Unknown section" } }, + } + } + } + } +} + +/// General settings section. +#[component] +fn GeneralSection( + state: Signal, + saving: bool, + error: Option, + on_save: EventHandler<()>, +) -> Element { + let mut state = state; + + rsx! { + div { class: "notebook-settings-section", + h2 { "General Settings" } + + // Title field. + div { class: "notebook-settings-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-settings-field", + label { "Path" } + input { + r#type: "text", + value: "{state.read().path}", + placeholder: "my-notebook", + oninput: move |e| state.write().path = e.value(), + } + span { class: "notebook-settings-hint", + "URL-friendly identifier for your notebook." + } + } + + // Publish globally toggle. + div { class: "notebook-settings-field notebook-settings-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-settings-hint", + "Enable cross-platform discovery via site.standard.* records." + } + } + + // Tags field. + div { class: "notebook-settings-field", + label { "Tags" } + div { class: "notebook-settings-tags", + for (i, tag) in state.read().tags.iter().enumerate() { + span { + key: "{i}", + class: "notebook-settings-tag", + "{tag}" + button { + class: "notebook-settings-tag-remove", + onclick: move |_| { + state.write().tags.remove(i); + }, + "×" + } + } + } + input { + r#type: "text", + class: "notebook-settings-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(); + } + } + }, + } + } + } + + // Content rating. + div { class: "notebook-settings-field", + label { "Content Rating" } + select { + value: state.read().rating.clone().unwrap_or_default(), + onchange: move |e: Event| { + let val = e.value(); + state.write().rating = if val.is_empty() { None } else { Some(val) }; + }, + option { value: "", "None" } + option { value: "general", "General" } + option { value: "mature", "Mature" } + option { value: "adult", "Adult" } + } + } + + // Error display. + if let Some(ref err) = error { + div { class: "notebook-settings-error", "{err}" } + } + + // Save button. + div { class: "notebook-settings-actions", + Button { + variant: ButtonVariant::Primary, + onclick: move |_| on_save.call(()), + disabled: saving, + if saving { "Saving..." } else { "Save Changes" } + } + } + } + } +} + +/// Theme settings section with full editor. +#[component] +fn ThemeSection( + values: Signal, + saving: bool, + on_save: EventHandler, +) -> Element { + rsx! { + div { class: "notebook-settings-section notebook-settings-theme", + h2 { "Theme Settings" } + p { class: "notebook-settings-description", + "Customize the appearance of your notebook with colours, fonts, and spacing." + } + + ThemeEditor { + values: values, + on_save: on_save, + on_cancel: move |_| {}, + saving: saving, + } + } + } +} + +/// Collaborators section. +#[component] +fn CollaboratorsSection() -> Element { + rsx! { + div { class: "notebook-settings-section", + h2 { "Collaborators" } + p { class: "notebook-settings-description", + "Manage who can edit this notebook." + } + + // TODO: Integrate CollaboratorsPanel when notebook URI is available. + div { class: "notebook-settings-placeholder", + "Collaborator management coming soon." + } + } + } +} + +/// Danger zone section. +#[component] +fn DangerSection(notebook_uri: Option>, on_deleted: EventHandler<()>) -> Element { + let fetcher = use_context::(); + let mut show_delete_confirm = use_signal(|| false); + let mut deleting = use_signal(|| false); + let mut delete_error = use_signal(|| None::); + + let delete_fetcher = fetcher.clone(); + let notebook_uri_for_delete = notebook_uri.clone(); + let handle_delete = move |_| { + let Some(uri) = notebook_uri_for_delete.clone() else { + delete_error.set(Some("Notebook not loaded".to_string())); + return; + }; + let fetcher = delete_fetcher.clone(); + + spawn(async move { + deleting.set(true); + delete_error.set(None); + + // Delete all entries first, then the book. + let rkey = match uri.rkey() { + Some(r) => match RecordKey::any(r.as_ref()) { + Ok(k) => k.into_static(), + Err(_) => { + delete_error.set(Some("Invalid record key".to_string())); + deleting.set(false); + return; + } + }, + None => { + delete_error.set(Some("Invalid notebook URI".to_string())); + deleting.set(false); + return; + } + }; + + let client = fetcher.get_client(); + match client.delete_record::(rkey).await { + Ok(_) => { + deleting.set(false); + show_delete_confirm.set(false); + on_deleted.call(()); + } + Err(e) => { + delete_error.set(Some(format!("Failed to delete: {:?}", e))); + deleting.set(false); + } + } + }); + }; + + rsx! { + div { class: "notebook-settings-section notebook-settings-danger", + h2 { "Danger Zone" } + + if let Some(ref err) = delete_error() { + div { class: "notebook-settings-error", "{err}" } + } + + div { class: "notebook-settings-danger-item", + div { class: "notebook-settings-danger-info", + h3 { "Delete Notebook" } + p { "Permanently delete this notebook and all its entries. This action cannot be undone." } + } + Button { + variant: ButtonVariant::Destructive, + onclick: move |_| show_delete_confirm.set(true), + disabled: notebook_uri.is_none(), + "Delete Notebook" + } + } + + if show_delete_confirm() { + div { class: "notebook-settings-confirm-overlay", + div { class: "notebook-settings-confirm-dialog", + h3 { "Are you sure?" } + p { "This will permanently delete the notebook and all its entries." } + div { class: "notebook-settings-confirm-actions", + Button { + variant: ButtonVariant::Destructive, + onclick: handle_delete, + disabled: deleting(), + if deleting() { "Deleting..." } else { "Yes, Delete" } + } + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| show_delete_confirm.set(false), + disabled: deleting(), + "Cancel" + } + } + } + } + } + } + } +} + +// --- Helper functions for full theme sync --- + +use jacquard::types::string::{Cid, RecordKey}; +use weaver_api::sh_weaver::notebook::colour_scheme::ColourSchemeColours; +use weaver_common::WeaverError; + +/// Result of syncing theme records. +pub struct FullThemeSyncResult { + pub theme_uri: AtUri<'static>, + pub theme_cid: Cid<'static>, +} + +/// Load full theme values from existing theme records. +async fn load_full_theme_values( + fetcher: &Fetcher, + theme_ref: &StrongRef<'_>, +) -> Result { + // Fetch Theme record. + let theme: Theme<'static> = fetcher + .fetch_record( + &Theme::uri(theme_ref.uri.as_ref()) + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, + ) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? + .value + .into_static(); + + // Fetch light ColourScheme. + let light_scheme: ColourScheme<'static> = fetcher + .fetch_record( + &ColourScheme::uri(theme.light_scheme.uri.as_ref()) + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, + ) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? + .value + .into_static(); + + // Fetch dark ColourScheme. + let dark_scheme: ColourScheme<'static> = fetcher + .fetch_record( + &ColourScheme::uri(theme.dark_scheme.uri.as_ref()) + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, + ) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? + .value + .into_static(); + + // Extract code themes. + let light_code_theme = match &theme.light_code_theme { + ThemeLightCodeTheme::CodeThemeName(name) => name.as_ref().to_string(), + ThemeLightCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(), + ThemeLightCodeTheme::Unknown(_) => "base16-ocean.light".to_string(), + }; + let dark_code_theme = match &theme.dark_code_theme { + ThemeDarkCodeTheme::CodeThemeName(name) => name.as_ref().to_string(), + ThemeDarkCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(), + ThemeDarkCodeTheme::Unknown(_) => "base16-ocean.dark".to_string(), + }; + + let default_mode = theme + .default_theme + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "auto".to_string()); + + fn colours_to_scheme(colours: &ColourSchemeColours<'_>) -> ColourSchemeValues { + ColourSchemeValues { + base: strip_hex(&colours.base), + surface: strip_hex(&colours.surface), + overlay: strip_hex(&colours.overlay), + text: strip_hex(&colours.text), + muted: strip_hex(&colours.muted), + subtle: strip_hex(&colours.subtle), + emphasis: strip_hex(&colours.emphasis), + primary: strip_hex(&colours.primary), + secondary: strip_hex(&colours.secondary), + tertiary: strip_hex(&colours.tertiary), + error: strip_hex(&colours.error), + warning: strip_hex(&colours.warning), + success: strip_hex(&colours.success), + border: strip_hex(&colours.border), + link: strip_hex(&colours.link), + highlight: strip_hex(&colours.highlight), + } + } + + Ok(ThemeEditorValues { + light: colours_to_scheme(&light_scheme.colours), + dark: colours_to_scheme(&dark_scheme.colours), + font_body: String::new(), + font_heading: String::new(), + font_mono: String::new(), + spacing_base: theme.spacing.base_size.to_string(), + spacing_line_height: theme.spacing.line_height.to_string(), + spacing_scale: theme.spacing.scale.to_string(), + light_code_theme, + dark_code_theme, + default_mode, + }) +} + +fn strip_hex(s: &str) -> String { + s.trim_start_matches('#') + .trim_start_matches("0x") + .trim_start_matches("0X") + .to_uppercase() +} + +/// Sync full theme values to ColourScheme and Theme records. +async fn sync_full_theme( + fetcher: &Fetcher, + existing_theme_ref: Option<&StrongRef<'_>>, + values: &ThemeEditorValues, +) -> Result { + fn scheme_to_colours(scheme: &ColourSchemeValues) -> ColourSchemeColours<'static> { + ColourSchemeColours { + base: format!("#{}", scheme.base).into(), + surface: format!("#{}", scheme.surface).into(), + overlay: format!("#{}", scheme.overlay).into(), + text: format!("#{}", scheme.text).into(), + muted: format!("#{}", scheme.muted).into(), + subtle: format!("#{}", scheme.subtle).into(), + emphasis: format!("#{}", scheme.emphasis).into(), + primary: format!("#{}", scheme.primary).into(), + secondary: format!("#{}", scheme.secondary).into(), + tertiary: format!("#{}", scheme.tertiary).into(), + error: format!("#{}", scheme.error).into(), + warning: format!("#{}", scheme.warning).into(), + success: format!("#{}", scheme.success).into(), + border: format!("#{}", scheme.border).into(), + link: format!("#{}", scheme.link).into(), + highlight: format!("#{}", scheme.highlight).into(), + extra_data: None, + } + } + + let light_colours = scheme_to_colours(&values.light); + let dark_colours = scheme_to_colours(&values.dark); + + let light_code = + ThemeLightCodeTheme::CodeThemeName(Box::new(CowStr::from(values.light_code_theme.clone()))); + let dark_code = + ThemeDarkCodeTheme::CodeThemeName(Box::new(CowStr::from(values.dark_code_theme.clone()))); + let default_theme: Option> = match values.default_mode.as_str() { + "light" => Some(CowStr::from("light")), + "dark" => Some(CowStr::from("dark")), + _ => None, + }; + + if let Some(theme_ref) = existing_theme_ref { + // UPDATE existing records. + let theme_uri = &theme_ref.uri; + let existing_theme: Theme<'static> = fetcher + .fetch_record( + &Theme::uri(theme_uri.as_ref()) + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, + ) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? + .value + .into_static(); + + // Update light ColourScheme. + let light_scheme_rkey = + existing_theme.light_scheme.uri.rkey().ok_or_else(|| { + WeaverError::InvalidNotebook("Light scheme URI missing rkey".into()) + })?; + let light_result = fetcher + .put_record( + RecordKey::any(light_scheme_rkey.as_ref()) + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? + .into_static(), + ColourScheme::new() + .name(CowStr::from("Custom Light")) + .variant(CowStr::from("light")) + .colours(light_colours) + .build(), + ) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; + + // Update dark ColourScheme. + let dark_scheme_rkey = + existing_theme.dark_scheme.uri.rkey().ok_or_else(|| { + WeaverError::InvalidNotebook("Dark scheme URI missing rkey".into()) + })?; + let dark_result = fetcher + .put_record( + RecordKey::any(dark_scheme_rkey.as_ref()) + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? + .into_static(), + ColourScheme::new() + .name(CowStr::from("Custom Dark")) + .variant(CowStr::from("dark")) + .colours(dark_colours) + .build(), + ) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; + + // Update Theme with new CIDs. + let theme_rkey = theme_uri + .rkey() + .ok_or_else(|| WeaverError::InvalidNotebook("Theme URI missing rkey".into()))?; + let theme_result = fetcher + .put_record( + RecordKey::any(theme_rkey.as_ref()) + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? + .into_static(), + Theme::new() + .light_scheme( + StrongRef::new() + .uri(light_result.uri.clone().into_static()) + .cid(light_result.cid.clone().into_static()) + .build(), + ) + .dark_scheme( + StrongRef::new() + .uri(dark_result.uri.clone().into_static()) + .cid(dark_result.cid.clone().into_static()) + .build(), + ) + .light_code_theme(light_code) + .dark_code_theme(dark_code) + .fonts( + ThemeFonts::new() + .body(vec![]) + .heading(vec![]) + .monospace(vec![]) + .build(), + ) + .spacing(ThemeSpacing { + base_size: CowStr::from(values.spacing_base.clone()), + line_height: CowStr::from(values.spacing_line_height.clone()), + scale: CowStr::from(values.spacing_scale.clone()), + extra_data: None, + }) + .maybe_default_theme(default_theme) + .build(), + ) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; + + Ok(FullThemeSyncResult { + theme_uri: theme_result.uri.into_static(), + theme_cid: theme_result.cid.into_static(), + }) + } else { + // CREATE new records. + let light_scheme = ColourScheme::new() + .name(CowStr::from("Custom Light")) + .variant(CowStr::from("light")) + .colours(light_colours) + .build(); + + let light_result = fetcher + .create_record(light_scheme, None) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; + + let dark_scheme = ColourScheme::new() + .name(CowStr::from("Custom Dark")) + .variant(CowStr::from("dark")) + .colours(dark_colours) + .build(); + + let dark_result = fetcher + .create_record(dark_scheme, None) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; + + let theme = Theme::new() + .light_scheme( + StrongRef::new() + .uri(light_result.uri.clone().into_static()) + .cid(light_result.cid.clone().into_static()) + .build(), + ) + .dark_scheme( + StrongRef::new() + .uri(dark_result.uri.clone().into_static()) + .cid(dark_result.cid.clone().into_static()) + .build(), + ) + .light_code_theme(light_code) + .dark_code_theme(dark_code) + .fonts( + ThemeFonts::new() + .body(vec![]) + .heading(vec![]) + .monospace(vec![]) + .build(), + ) + .spacing(ThemeSpacing { + base_size: CowStr::from(values.spacing_base.clone()), + line_height: CowStr::from(values.spacing_line_height.clone()), + scale: CowStr::from(values.spacing_scale.clone()), + extra_data: None, + }) + .maybe_default_theme(default_theme) + .build(); + + let theme_result = fetcher + .create_record(theme, None) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; + + Ok(FullThemeSyncResult { + theme_uri: theme_result.uri.into_static(), + theme_cid: theme_result.cid.into_static(), + }) + } +}