at main 314 lines 10 kB view raw
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 (&current_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 (&current_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}