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