atproto blogging
1//! Actions sidebar/menubar for profile page.
2
3use crate::Route;
4use crate::auth::AuthState;
5use crate::components::app_link::{AppLink, AppLinkTarget};
6use crate::components::button::{Button, ButtonVariant};
7use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle};
8use crate::components::notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState};
9use crate::fetch::Fetcher;
10use dioxus::prelude::*;
11use jacquard::IntoStatic;
12use jacquard::client::AgentSessionExt;
13use jacquard::types::ident::AtIdentifier;
14use jacquard::types::string::Datetime;
15use weaver_api::sh_weaver::actor::Author;
16use weaver_api::sh_weaver::notebook::book::Book;
17use weaver_common::slugify;
18
19const PROFILE_ACTIONS_CSS: Asset = asset!("/assets/styling/profile-actions.css");
20
21/// Build a Book record from form state.
22fn build_book_from_form(
23 form_state: &NotebookFormState,
24 did: &jacquard::types::string::Did<'_>,
25 path: String,
26) -> Book<'static> {
27 let now = Datetime::now();
28 let author = Author::new().did(did.clone().into_static()).build();
29
30 let title = form_state.title.clone();
31
32 let tags: Option<Vec<_>> = if form_state.tags.is_empty() {
33 None
34 } else {
35 Some(form_state.tags.iter().map(|s| s.clone().into()).collect())
36 };
37
38 Book::new()
39 .authors(vec![author])
40 .entry_list(vec![])
41 .maybe_title(Some(title.into()))
42 .maybe_path(Some(path.into()))
43 .maybe_publish_global(Some(form_state.publish_global))
44 .maybe_tags(tags)
45 .created_at(now.clone())
46 .updated_at(now)
47 .build()
48}
49
50/// Actions available on the profile page for the owner.
51#[component]
52pub fn ProfileActions(ident: ReadSignal<AtIdentifier<'static>>) -> Element {
53 let auth_state = use_context::<Signal<AuthState>>();
54 let fetcher = use_context::<Fetcher>();
55 let navigator = use_navigator();
56
57 // State for the create notebook dialog.
58 let mut show_create_dialog = use_signal(|| false);
59 let mut saving = use_signal(|| false);
60 let mut error = use_signal(|| None::<String>);
61
62 // Check if viewing own profile.
63 let is_owner = {
64 let current_did = auth_state.read().did.clone();
65 match (¤t_did, ident()) {
66 (Some(did), AtIdentifier::Did(ref ident_did)) => *did == *ident_did,
67 _ => false,
68 }
69 };
70
71 if !is_owner {
72 return rsx! {};
73 }
74
75 let create_fetcher = fetcher.clone();
76 let handle_save = move |form_state: NotebookFormState| {
77 let fetcher = create_fetcher.clone();
78 let navigator = navigator.clone();
79 let ident_value = ident();
80
81 spawn(async move {
82 saving.set(true);
83 error.set(None);
84
85 let did = match fetcher.current_did().await {
86 Some(d) => d,
87 None => {
88 error.set(Some("Not authenticated".to_string()));
89 saving.set(false);
90 return;
91 }
92 };
93
94 let path = if form_state.path.is_empty() {
95 slugify(&form_state.title)
96 } else {
97 form_state.path.clone()
98 };
99
100 let book = build_book_from_form(&form_state, &did, path.clone());
101
102 match fetcher.create_record(book, None).await {
103 Ok(_output) => {
104 // TODO: If publish_global, create site.standard.publication record (Task 8).
105 show_create_dialog.set(false);
106 saving.set(false);
107 navigator.push(Route::NotebookIndex {
108 ident: ident_value,
109 book_title: path.into(),
110 });
111 }
112 Err(e) => {
113 error.set(Some(format!("Failed to create notebook: {:?}", e)));
114 saving.set(false);
115 }
116 }
117 });
118 };
119
120 let handle_cancel = move |_| {
121 show_create_dialog.set(false);
122 error.set(None);
123 };
124
125 rsx! {
126 document::Link { rel: "stylesheet", href: PROFILE_ACTIONS_CSS }
127
128 aside { class: "profile-actions",
129 div { class: "profile-actions-container",
130 div { class: "profile-actions-list",
131 AppLink {
132 to: AppLinkTarget::NewDraft { ident: ident(), notebook: None },
133 class: "profile-action-link".to_string(),
134 Button {
135 variant: ButtonVariant::Outline,
136 "New Entry"
137 }
138 }
139
140 Button {
141 variant: ButtonVariant::Outline,
142 onclick: move |_| show_create_dialog.set(true),
143 "New Notebook"
144 }
145
146 AppLink {
147 to: AppLinkTarget::Drafts { ident: ident() },
148 class: "profile-action-link".to_string(),
149 Button {
150 variant: ButtonVariant::Ghost,
151 "Drafts"
152 }
153 }
154
155 AppLink {
156 to: AppLinkTarget::Invites { ident: ident() },
157 class: "profile-action-link".to_string(),
158 Button {
159 variant: ButtonVariant::Ghost,
160 "Invites"
161 }
162 }
163 }
164 }
165 }
166
167 // Create notebook dialog.
168 DialogRoot {
169 open: show_create_dialog(),
170 on_open_change: move |open: bool| show_create_dialog.set(open),
171 DialogContent {
172 DialogTitle { "Create Notebook" }
173 DialogDescription {
174 "Create a new notebook to organize your entries."
175 }
176 NotebookEditor {
177 mode: NotebookEditorMode::Create,
178 on_save: handle_save,
179 on_cancel: handle_cancel,
180 saving: saving(),
181 error: error(),
182 }
183 }
184 }
185 }
186}
187
188
189/// Mobile-friendly menubar version of profile actions.
190#[component]
191pub fn ProfileActionsMenubar(ident: ReadSignal<AtIdentifier<'static>>) -> Element {
192 let auth_state = use_context::<Signal<AuthState>>();
193 let fetcher = use_context::<Fetcher>();
194 let navigator = use_navigator();
195
196 let mut show_create_dialog = use_signal(|| false);
197 let mut saving = use_signal(|| false);
198 let mut error = use_signal(|| None::<String>);
199
200 let is_owner = {
201 let current_did = auth_state.read().did.clone();
202 match (¤t_did, ident()) {
203 (Some(did), AtIdentifier::Did(ref ident_did)) => *did == *ident_did,
204 _ => false,
205 }
206 };
207
208 if !is_owner {
209 return rsx! {};
210 }
211
212 let create_fetcher = fetcher.clone();
213 let handle_save = move |form_state: NotebookFormState| {
214 let fetcher = create_fetcher.clone();
215 let navigator = navigator.clone();
216 let ident_value = ident();
217
218 spawn(async move {
219 saving.set(true);
220 error.set(None);
221
222 let did = match fetcher.current_did().await {
223 Some(d) => d,
224 None => {
225 error.set(Some("Not authenticated".to_string()));
226 saving.set(false);
227 return;
228 }
229 };
230
231 let path = if form_state.path.is_empty() {
232 slugify(&form_state.title)
233 } else {
234 form_state.path.clone()
235 };
236
237 let book = build_book_from_form(&form_state, &did, path.clone());
238
239 match fetcher.create_record(book, None).await {
240 Ok(_output) => {
241 // TODO: If publish_global, create site.standard.publication record (Task 8).
242 show_create_dialog.set(false);
243 saving.set(false);
244 navigator.push(Route::NotebookIndex {
245 ident: ident_value,
246 book_title: path.into(),
247 });
248 }
249 Err(e) => {
250 error.set(Some(format!("Failed to create notebook: {:?}", e)));
251 saving.set(false);
252 }
253 }
254 });
255 };
256
257 let handle_cancel = move |_| {
258 show_create_dialog.set(false);
259 error.set(None);
260 };
261
262 rsx! {
263 div { class: "profile-actions-menubar",
264 AppLink {
265 to: AppLinkTarget::NewDraft { ident: ident(), notebook: None },
266 Button {
267 variant: ButtonVariant::Primary,
268 "New Entry"
269 }
270 }
271
272 Button {
273 variant: ButtonVariant::Outline,
274 onclick: move |_| show_create_dialog.set(true),
275 "New Notebook"
276 }
277
278 AppLink {
279 to: AppLinkTarget::Drafts { ident: ident() },
280 Button {
281 variant: ButtonVariant::Ghost,
282 "Drafts"
283 }
284 }
285
286 AppLink {
287 to: AppLinkTarget::Invites { ident: ident() },
288 Button {
289 variant: ButtonVariant::Ghost,
290 "Invites"
291 }
292 }
293 }
294
295 // Create notebook dialog.
296 DialogRoot {
297 open: show_create_dialog(),
298 on_open_change: move |open: bool| show_create_dialog.set(open),
299 DialogContent {
300 DialogTitle { "Create Notebook" }
301 DialogDescription {
302 "Create a new notebook to organize your entries."
303 }
304 NotebookEditor {
305 mode: NotebookEditorMode::Create,
306 on_save: handle_save,
307 on_cancel: handle_cancel,
308 saving: saving(),
309 error: error(),
310 }
311 }
312 }
313 }
314}