tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
[weaver-app] enable New Notebook button with creation dialog
Orual
3 weeks ago
9dbd258c
2477a658
+388
-74
2 changed files
expand all
collapse all
unified
split
crates
weaver-app
assets
styling
notebook-editor.css
src
components
profile_actions.rs
+67
-71
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;
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;
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;
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;
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;
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;
33
33
+
padding: 0.5rem;
34
34
+
border: 1px solid var(--color-border);
35
35
+
background: var(--color-base);
36
36
+
color: var(--color-text);
37
37
+
font-size: 0.875rem;
39
38
}
40
39
41
40
.notebook-editor-field input[type="text"]:focus {
42
42
-
outline: 2px solid var(--color-primary);
43
43
-
outline-offset: -1px;
41
41
+
outline: 2px solid var(--color-primary);
42
42
+
outline-offset: -1px;
44
43
}
45
44
46
45
.notebook-editor-hint {
47
47
-
font-size: 0.75rem;
48
48
-
color: var(--color-text-muted);
46
46
+
font-size: 0.75rem;
47
47
+
color: var(--color-muted);
49
48
}
50
49
51
50
.notebook-editor-toggle {
52
52
-
flex-direction: row;
53
53
-
align-items: flex-start;
54
54
-
gap: 0.5rem;
51
51
+
flex-direction: row;
52
52
+
align-items: flex-start;
53
53
+
gap: 0.5rem;
55
54
}
56
55
57
56
.notebook-editor-toggle label {
58
58
-
display: flex;
59
59
-
align-items: center;
60
60
-
gap: 0.5rem;
61
61
-
cursor: pointer;
57
57
+
display: flex;
58
58
+
align-items: center;
59
59
+
gap: 0.5rem;
60
60
+
cursor: pointer;
62
61
}
63
62
64
63
.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);
64
64
+
display: flex;
65
65
+
flex-wrap: wrap;
66
66
+
gap: 0.5rem;
67
67
+
padding: 0.5rem;
68
68
+
border: 1px solid var(--color-border);
69
69
+
background: var(--color-base);
72
70
}
73
71
74
72
.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;
73
73
+
display: flex;
74
74
+
align-items: center;
75
75
+
gap: 0.25rem;
76
76
+
padding: 0.25rem 0.5rem;
77
77
+
background: var(--color-surface);
78
78
+
font-size: 0.75rem;
82
79
}
83
80
84
81
.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;
82
82
+
background: none;
83
83
+
border: none;
84
84
+
color: var(--color-muted);
85
85
+
cursor: pointer;
86
86
+
padding: 0;
87
87
+
font-size: 1rem;
88
88
+
line-height: 1;
92
89
}
93
90
94
91
.notebook-editor-tag-remove:hover {
95
95
-
color: var(--color-error);
92
92
+
color: var(--color-error);
96
93
}
97
94
98
95
.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);
96
96
+
flex: 1;
97
97
+
min-width: 8rem;
98
98
+
border: none;
99
99
+
background: transparent;
100
100
+
font-size: 0.875rem;
101
101
+
color: var(--color-text);
105
102
}
106
103
107
104
.notebook-editor-tags-input:focus {
108
108
-
outline: none;
105
105
+
outline: none;
109
106
}
110
107
111
108
.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;
109
109
+
padding: 0.75rem;
110
110
+
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface));
111
111
+
border: 1px solid var(--color-error);
112
112
+
color: var(--color-error);
113
113
+
font-size: 0.875rem;
118
114
}
119
115
120
116
.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);
117
117
+
display: flex;
118
118
+
gap: 0.5rem;
119
119
+
justify-content: flex-end;
120
120
+
padding-top: 0.5rem;
121
121
+
border-top: 1px solid var(--color-border);
126
122
}
+321
-3
crates/weaver-app/src/components/profile_actions.rs
···
3
3
use crate::auth::AuthState;
4
4
use crate::components::app_link::{AppLink, AppLinkTarget};
5
5
use crate::components::button::{Button, ButtonVariant};
6
6
+
use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle};
7
7
+
use crate::components::notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState};
8
8
+
use crate::fetch::Fetcher;
9
9
+
use crate::Route;
6
10
use dioxus::prelude::*;
7
11
use jacquard::types::ident::AtIdentifier;
12
12
+
use weaver_api::com_atproto::repo::create_record::CreateRecord;
13
13
+
use weaver_api::sh_weaver::actor::Author;
14
14
+
use weaver_api::sh_weaver::notebook::book::Book;
8
15
9
16
const PROFILE_ACTIONS_CSS: Asset = asset!("/assets/styling/profile-actions.css");
10
17
···
12
19
#[component]
13
20
pub fn ProfileActions(ident: ReadSignal<AtIdentifier<'static>>) -> Element {
14
21
let auth_state = use_context::<Signal<AuthState>>();
22
22
+
let fetcher = use_context::<Fetcher>();
23
23
+
let navigator = use_navigator();
15
24
16
16
-
// Check if viewing own profile
25
25
+
// State for the create notebook dialog.
26
26
+
let mut show_create_dialog = use_signal(|| false);
27
27
+
let mut saving = use_signal(|| false);
28
28
+
let mut error = use_signal(|| None::<String>);
29
29
+
30
30
+
// Check if viewing own profile.
17
31
let is_owner = {
18
32
let current_did = auth_state.read().did.clone();
19
33
match (¤t_did, ident()) {
···
26
40
return rsx! {};
27
41
}
28
42
43
43
+
// Handler for saving a new notebook.
44
44
+
let create_fetcher = fetcher.clone();
45
45
+
let handle_save = move |form_state: NotebookFormState| {
46
46
+
let fetcher = create_fetcher.clone();
47
47
+
let navigator = navigator.clone();
48
48
+
let ident_value = ident();
49
49
+
50
50
+
spawn(async move {
51
51
+
use jacquard::prelude::*;
52
52
+
use jacquard::types::string::{Datetime, Nsid};
53
53
+
54
54
+
saving.set(true);
55
55
+
error.set(None);
56
56
+
57
57
+
let client = fetcher.get_client();
58
58
+
59
59
+
// Get current DID.
60
60
+
let did = match fetcher.current_did().await {
61
61
+
Some(d) => d,
62
62
+
None => {
63
63
+
error.set(Some("Not authenticated".to_string()));
64
64
+
saving.set(false);
65
65
+
return;
66
66
+
}
67
67
+
};
68
68
+
69
69
+
// Build the Book record.
70
70
+
let now = Datetime::now();
71
71
+
72
72
+
// Create author from current DID.
73
73
+
let author = Author::new().did(did.clone()).build();
74
74
+
75
75
+
// Generate path from title if empty.
76
76
+
let path = if form_state.path.is_empty() {
77
77
+
slugify(&form_state.title)
78
78
+
} else {
79
79
+
form_state.path.clone()
80
80
+
};
81
81
+
let path_for_navigation = path.clone();
82
82
+
83
83
+
// Build tags if present.
84
84
+
let tags: Option<Vec<_>> = if form_state.tags.is_empty() {
85
85
+
None
86
86
+
} else {
87
87
+
Some(form_state.tags.iter().map(|s| s.as_str().into()).collect())
88
88
+
};
89
89
+
90
90
+
// Build the notebook.
91
91
+
let book = Book::new()
92
92
+
.authors(vec![author])
93
93
+
.entry_list(vec![]) // New notebook starts empty.
94
94
+
.maybe_title(Some(form_state.title.as_str().into()))
95
95
+
.maybe_path(Some(path.as_str().into()))
96
96
+
.maybe_publish_global(Some(form_state.publish_global))
97
97
+
.maybe_tags(tags)
98
98
+
.created_at(now.clone())
99
99
+
.updated_at(now)
100
100
+
.build();
101
101
+
102
102
+
// Serialize to Data.
103
103
+
let book_data = match jacquard::to_data(&book) {
104
104
+
Ok(d) => d,
105
105
+
Err(e) => {
106
106
+
error.set(Some(format!("Failed to serialize notebook: {:?}", e)));
107
107
+
saving.set(false);
108
108
+
return;
109
109
+
}
110
110
+
};
111
111
+
112
112
+
// Create the record.
113
113
+
let request = CreateRecord::new()
114
114
+
.repo(AtIdentifier::Did(did.clone()))
115
115
+
.collection(Nsid::new_static("sh.weaver.notebook.book").unwrap())
116
116
+
.record(book_data)
117
117
+
.build();
118
118
+
119
119
+
match client.send(request).await {
120
120
+
Ok(resp) => {
121
121
+
match resp.into_output() {
122
122
+
Ok(_output) => {
123
123
+
// TODO: If publish_global, create site.standard.publication record (Task 8).
124
124
+
125
125
+
// Navigate to the new notebook.
126
126
+
show_create_dialog.set(false);
127
127
+
saving.set(false);
128
128
+
129
129
+
// Navigate using path or title.
130
130
+
let book_title = if path_for_navigation.is_empty() {
131
131
+
form_state.title.clone()
132
132
+
} else {
133
133
+
path_for_navigation
134
134
+
};
135
135
+
navigator.push(Route::NotebookIndex {
136
136
+
ident: ident_value,
137
137
+
book_title: book_title.into(),
138
138
+
});
139
139
+
}
140
140
+
Err(e) => {
141
141
+
error.set(Some(format!("Failed to create notebook: {:?}", e)));
142
142
+
saving.set(false);
143
143
+
}
144
144
+
}
145
145
+
}
146
146
+
Err(e) => {
147
147
+
error.set(Some(format!("Failed to create notebook: {:?}", e)));
148
148
+
saving.set(false);
149
149
+
}
150
150
+
}
151
151
+
});
152
152
+
};
153
153
+
154
154
+
let handle_cancel = move |_| {
155
155
+
show_create_dialog.set(false);
156
156
+
error.set(None);
157
157
+
};
158
158
+
29
159
rsx! {
30
160
document::Link { rel: "stylesheet", href: PROFILE_ACTIONS_CSS }
31
161
···
41
171
}
42
172
}
43
173
44
44
-
// TODO: New Notebook button (disabled for now)
45
174
Button {
46
175
variant: ButtonVariant::Outline,
47
47
-
disabled: true,
176
176
+
onclick: move |_| show_create_dialog.set(true),
48
177
"New Notebook"
49
178
}
50
179
···
68
197
}
69
198
}
70
199
}
200
200
+
201
201
+
// Create notebook dialog.
202
202
+
DialogRoot {
203
203
+
open: show_create_dialog(),
204
204
+
on_open_change: move |open: bool| show_create_dialog.set(open),
205
205
+
DialogContent {
206
206
+
DialogTitle { "Create Notebook" }
207
207
+
DialogDescription {
208
208
+
"Create a new notebook to organize your entries."
209
209
+
}
210
210
+
NotebookEditor {
211
211
+
mode: NotebookEditorMode::Create,
212
212
+
on_save: handle_save,
213
213
+
on_cancel: handle_cancel,
214
214
+
saving: saving(),
215
215
+
error: error(),
216
216
+
}
217
217
+
}
218
218
+
}
71
219
}
72
220
}
73
221
222
222
+
/// Simple slug generation from title.
223
223
+
fn slugify(title: &str) -> String {
224
224
+
title
225
225
+
.to_lowercase()
226
226
+
.chars()
227
227
+
.map(|c| {
228
228
+
if c.is_ascii_alphanumeric() {
229
229
+
c
230
230
+
} else if c.is_whitespace() || c == '-' || c == '_' {
231
231
+
'-'
232
232
+
} else {
233
233
+
'\0'
234
234
+
}
235
235
+
})
236
236
+
.filter(|&c| c != '\0')
237
237
+
.collect::<String>()
238
238
+
.split('-')
239
239
+
.filter(|s| !s.is_empty())
240
240
+
.collect::<Vec<_>>()
241
241
+
.join("-")
242
242
+
}
243
243
+
74
244
/// Mobile-friendly menubar version of profile actions.
75
245
#[component]
76
246
pub fn ProfileActionsMenubar(ident: ReadSignal<AtIdentifier<'static>>) -> Element {
77
247
let auth_state = use_context::<Signal<AuthState>>();
248
248
+
let fetcher = use_context::<Fetcher>();
249
249
+
let navigator = use_navigator();
250
250
+
251
251
+
// State for the create notebook dialog.
252
252
+
let mut show_create_dialog = use_signal(|| false);
253
253
+
let mut saving = use_signal(|| false);
254
254
+
let mut error = use_signal(|| None::<String>);
78
255
79
256
let is_owner = {
80
257
let current_did = auth_state.read().did.clone();
···
88
265
return rsx! {};
89
266
}
90
267
268
268
+
// Handler for saving a new notebook.
269
269
+
let create_fetcher = fetcher.clone();
270
270
+
let handle_save = move |form_state: NotebookFormState| {
271
271
+
let fetcher = create_fetcher.clone();
272
272
+
let navigator = navigator.clone();
273
273
+
let ident_value = ident();
274
274
+
275
275
+
spawn(async move {
276
276
+
use jacquard::prelude::*;
277
277
+
use jacquard::types::string::{Datetime, Nsid};
278
278
+
279
279
+
saving.set(true);
280
280
+
error.set(None);
281
281
+
282
282
+
let client = fetcher.get_client();
283
283
+
284
284
+
// Get current DID.
285
285
+
let did = match fetcher.current_did().await {
286
286
+
Some(d) => d,
287
287
+
None => {
288
288
+
error.set(Some("Not authenticated".to_string()));
289
289
+
saving.set(false);
290
290
+
return;
291
291
+
}
292
292
+
};
293
293
+
294
294
+
// Build the Book record.
295
295
+
let now = Datetime::now();
296
296
+
297
297
+
// Create author from current DID.
298
298
+
let author = Author::new().did(did.clone()).build();
299
299
+
300
300
+
// Generate path from title if empty.
301
301
+
let path = if form_state.path.is_empty() {
302
302
+
slugify(&form_state.title)
303
303
+
} else {
304
304
+
form_state.path.clone()
305
305
+
};
306
306
+
let path_for_navigation = path.clone();
307
307
+
308
308
+
// Build tags if present.
309
309
+
let tags: Option<Vec<_>> = if form_state.tags.is_empty() {
310
310
+
None
311
311
+
} else {
312
312
+
Some(form_state.tags.iter().map(|s| s.as_str().into()).collect())
313
313
+
};
314
314
+
315
315
+
// Build the notebook.
316
316
+
let book = Book::new()
317
317
+
.authors(vec![author])
318
318
+
.entry_list(vec![]) // New notebook starts empty.
319
319
+
.maybe_title(Some(form_state.title.as_str().into()))
320
320
+
.maybe_path(Some(path.as_str().into()))
321
321
+
.maybe_publish_global(Some(form_state.publish_global))
322
322
+
.maybe_tags(tags)
323
323
+
.created_at(now.clone())
324
324
+
.updated_at(now)
325
325
+
.build();
326
326
+
327
327
+
// Serialize to Data.
328
328
+
let book_data = match jacquard::to_data(&book) {
329
329
+
Ok(d) => d,
330
330
+
Err(e) => {
331
331
+
error.set(Some(format!("Failed to serialize notebook: {:?}", e)));
332
332
+
saving.set(false);
333
333
+
return;
334
334
+
}
335
335
+
};
336
336
+
337
337
+
// Create the record.
338
338
+
let request = CreateRecord::new()
339
339
+
.repo(AtIdentifier::Did(did.clone()))
340
340
+
.collection(Nsid::new_static("sh.weaver.notebook.book").unwrap())
341
341
+
.record(book_data)
342
342
+
.build();
343
343
+
344
344
+
match client.send(request).await {
345
345
+
Ok(resp) => {
346
346
+
match resp.into_output() {
347
347
+
Ok(_output) => {
348
348
+
// TODO: If publish_global, create site.standard.publication record (Task 8).
349
349
+
350
350
+
// Navigate to the new notebook.
351
351
+
show_create_dialog.set(false);
352
352
+
saving.set(false);
353
353
+
354
354
+
// Navigate using path or title.
355
355
+
let book_title = if path_for_navigation.is_empty() {
356
356
+
form_state.title.clone()
357
357
+
} else {
358
358
+
path_for_navigation
359
359
+
};
360
360
+
navigator.push(Route::NotebookIndex {
361
361
+
ident: ident_value,
362
362
+
book_title: book_title.into(),
363
363
+
});
364
364
+
}
365
365
+
Err(e) => {
366
366
+
error.set(Some(format!("Failed to create notebook: {:?}", e)));
367
367
+
saving.set(false);
368
368
+
}
369
369
+
}
370
370
+
}
371
371
+
Err(e) => {
372
372
+
error.set(Some(format!("Failed to create notebook: {:?}", e)));
373
373
+
saving.set(false);
374
374
+
}
375
375
+
}
376
376
+
});
377
377
+
};
378
378
+
379
379
+
let handle_cancel = move |_| {
380
380
+
show_create_dialog.set(false);
381
381
+
error.set(None);
382
382
+
};
383
383
+
91
384
rsx! {
92
385
div { class: "profile-actions-menubar",
93
386
AppLink {
···
98
391
}
99
392
}
100
393
394
394
+
Button {
395
395
+
variant: ButtonVariant::Outline,
396
396
+
onclick: move |_| show_create_dialog.set(true),
397
397
+
"New Notebook"
398
398
+
}
399
399
+
101
400
AppLink {
102
401
to: AppLinkTarget::Drafts { ident: ident() },
103
402
Button {
···
111
410
Button {
112
411
variant: ButtonVariant::Ghost,
113
412
"Invites"
413
413
+
}
414
414
+
}
415
415
+
}
416
416
+
417
417
+
// Create notebook dialog.
418
418
+
DialogRoot {
419
419
+
open: show_create_dialog(),
420
420
+
on_open_change: move |open: bool| show_create_dialog.set(open),
421
421
+
DialogContent {
422
422
+
DialogTitle { "Create Notebook" }
423
423
+
DialogDescription {
424
424
+
"Create a new notebook to organize your entries."
425
425
+
}
426
426
+
NotebookEditor {
427
427
+
mode: NotebookEditorMode::Create,
428
428
+
on_save: handle_save,
429
429
+
on_cancel: handle_cancel,
430
430
+
saving: saving(),
431
431
+
error: error(),
114
432
}
115
433
}
116
434
}