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