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