at main 558 lines 22 kB view raw
1//! Action buttons for entries (edit, delete, remove from notebook, pin/unpin). 2 3use crate::components::{AppLink, AppLinkTarget, use_app_navigate}; 4use crate::auth::AuthState; 5use crate::components::button::{Button, ButtonVariant}; 6use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; 7use crate::fetch::Fetcher; 8use dioxus::prelude::*; 9use jacquard::smol_str::SmolStr; 10use jacquard::types::aturi::AtUri; 11use jacquard::types::ident::AtIdentifier; 12use jacquard::types::string::Cid; 13use jacquard::IntoStatic; 14use weaver_api::com_atproto::repo::put_record::PutRecord; 15use weaver_api::com_atproto::repo::strong_ref::StrongRef; 16use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile; 17use weaver_api::sh_weaver::notebook::PermissionsState; 18 19const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css"); 20 21#[derive(Props, Clone, PartialEq)] 22pub struct EntryActionsProps { 23 /// The AT-URI of the entry 24 pub entry_uri: AtUri<'static>, 25 /// The CID of the entry (for StrongRef when pinning) 26 pub entry_cid: Cid<'static>, 27 /// The entry title (for display in confirmation) 28 pub entry_title: String, 29 /// Whether this entry is in a notebook (enables "remove from notebook") 30 #[props(default = false)] 31 pub in_notebook: bool, 32 /// Notebook title (if in_notebook is true, used for edit route) 33 #[props(default)] 34 pub notebook_title: Option<SmolStr>, 35 /// Whether this entry is currently pinned 36 #[props(default = false)] 37 pub is_pinned: bool, 38 /// Permissions state for edit access checking (if available) 39 #[props(default)] 40 pub permissions: Option<PermissionsState<'static>>, 41 /// Callback when entry is removed from notebook (for optimistic UI update) 42 #[props(default)] 43 pub on_removed: Option<EventHandler<()>>, 44 /// Callback when pin state changes 45 #[props(default)] 46 pub on_pinned_changed: Option<EventHandler<bool>>, 47} 48 49/// Action buttons for an entry: edit, delete, optionally remove from notebook. 50#[component] 51pub fn EntryActions(props: EntryActionsProps) -> Element { 52 let auth_state = use_context::<Signal<AuthState>>(); 53 let fetcher = use_context::<Fetcher>(); 54 55 let mut show_delete_confirm = use_signal(|| false); 56 let mut show_remove_confirm = use_signal(|| false); 57 let mut show_dropdown = use_signal(|| false); 58 let mut deleting = use_signal(|| false); 59 let mut removing = use_signal(|| false); 60 let mut pinning = use_signal(|| false); 61 let mut error = use_signal(|| None::<String>); 62 63 // Check edit access - use permissions if available, fall back to ownership check 64 let current_did = auth_state.read().did.clone(); 65 let can_edit = match &current_did { 66 Some(did) => { 67 if let Some(ref perms) = props.permissions { 68 // Use ACL-based permissions 69 perms.editors.iter().any(|grant| grant.did == *did) 70 } else { 71 // Fall back to ownership check 72 match props.entry_uri.authority() { 73 AtIdentifier::Did(entry_did) => *did == *entry_did, 74 _ => false, 75 } 76 } 77 } 78 None => false, 79 }; 80 81 if !can_edit { 82 return rsx! {}; 83 } 84 85 // Extract rkey from URI for edit route 86 let rkey = match props.entry_uri.rkey() { 87 Some(r) => r.0.to_string(), 88 None => return rsx! {}, // Can't edit without rkey 89 }; 90 91 // Build edit link target based on whether entry is in a notebook 92 let ident = props.entry_uri.authority().clone(); 93 let edit_target = if props.in_notebook { 94 if let Some(ref notebook) = props.notebook_title { 95 AppLinkTarget::EntryEdit { 96 ident: ident.clone().into_static(), 97 book_title: notebook.clone(), 98 rkey: rkey.clone().into(), 99 } 100 } else { 101 AppLinkTarget::StandaloneEntryEdit { 102 ident: ident.clone().into_static(), 103 rkey: rkey.clone().into(), 104 } 105 } 106 } else { 107 AppLinkTarget::StandaloneEntryEdit { 108 ident: ident.clone().into_static(), 109 rkey: rkey.clone().into(), 110 } 111 }; 112 113 // Get navigation function for post-delete redirect 114 let navigate = use_app_navigate(); 115 116 let entry_uri_for_delete = props.entry_uri.clone(); 117 let entry_title = props.entry_title.clone(); 118 119 let delete_fetcher = fetcher.clone(); 120 let handle_delete = move |_| { 121 let fetcher = delete_fetcher.clone(); 122 let uri = entry_uri_for_delete.clone(); 123 let navigate = navigate.clone(); 124 125 spawn(async move { 126 use jacquard::client::AgentSessionExt; 127 use weaver_api::sh_weaver::notebook::entry::Entry; 128 129 deleting.set(true); 130 error.set(None); 131 132 let rkey = match uri.rkey() { 133 Some(r) => r.clone().into_static(), 134 None => { 135 error.set(Some("Invalid entry URI".to_string())); 136 deleting.set(false); 137 return; 138 } 139 }; 140 141 let did = match fetcher.current_did().await { 142 Some(d) => d, 143 None => { 144 error.set(Some("Not authenticated".to_string())); 145 deleting.set(false); 146 return; 147 } 148 }; 149 150 let client = fetcher.get_client(); 151 match client.delete_record::<Entry>(rkey).await { 152 Ok(_) => { 153 show_delete_confirm.set(false); 154 // Navigate to profile after delete. 155 navigate(AppLinkTarget::Profile { 156 ident: AtIdentifier::Did(did), 157 }); 158 } 159 Err(e) => { 160 error.set(Some(format!("Delete failed: {:?}", e))); 161 } 162 } 163 deleting.set(false); 164 }); 165 }; 166 167 // Handler for removing entry from notebook (keeps entry, just removes from notebook's list) 168 let entry_uri_for_remove = props.entry_uri.clone(); 169 let notebook_title_for_remove = props.notebook_title.clone(); 170 let on_removed = props.on_removed.clone(); 171 let remove_fetcher = fetcher.clone(); 172 let handle_remove_from_notebook = move |_| { 173 let fetcher = remove_fetcher.clone(); 174 let entry_uri = entry_uri_for_remove.clone(); 175 let notebook_title = notebook_title_for_remove.clone(); 176 let on_removed = on_removed.clone(); 177 178 spawn(async move { 179 use jacquard::{from_data, to_data, prelude::*, types::string::Nsid}; 180 use weaver_api::sh_weaver::notebook::book::Book; 181 182 let client = fetcher.get_client(); 183 184 removing.set(true); 185 error.set(None); 186 187 let notebook_title = match notebook_title { 188 Some(t) => t, 189 None => { 190 error.set(Some("No notebook specified".to_string())); 191 removing.set(false); 192 return; 193 } 194 }; 195 196 let did = match fetcher.current_did().await { 197 Some(d) => d, 198 None => { 199 error.set(Some("Not authenticated".to_string())); 200 removing.set(false); 201 return; 202 } 203 }; 204 205 // Get the notebook by title 206 let ident = AtIdentifier::Did(did.clone()); 207 let notebook_result = fetcher.get_notebook(ident.clone(), notebook_title.clone()).await; 208 209 let (notebook_view, _) = match notebook_result { 210 Ok(Some(data)) => data.as_ref().clone(), 211 Ok(None) => { 212 error.set(Some("Notebook not found".to_string())); 213 removing.set(false); 214 return; 215 } 216 Err(e) => { 217 error.set(Some(format!("Failed to get notebook: {:?}", e))); 218 removing.set(false); 219 return; 220 } 221 }; 222 223 // Parse the book record to get the entry_list 224 let mut book: Book = match from_data(&notebook_view.record) { 225 Ok(b) => b, 226 Err(e) => { 227 error.set(Some(format!("Failed to parse notebook: {:?}", e))); 228 removing.set(false); 229 return; 230 } 231 }; 232 233 // Filter out the entry 234 let entry_uri_str = entry_uri.as_str(); 235 let original_len = book.entry_list.len(); 236 book.entry_list.retain(|ref_| ref_.uri.as_str() != entry_uri_str); 237 238 if book.entry_list.len() == original_len { 239 error.set(Some("Entry not found in notebook".to_string())); 240 removing.set(false); 241 return; 242 } 243 244 // Get the notebook's rkey from its URI 245 let notebook_rkey = match notebook_view.uri.rkey() { 246 Some(r) => r, 247 None => { 248 error.set(Some("Invalid notebook URI".to_string())); 249 removing.set(false); 250 return; 251 } 252 }; 253 254 // Convert book to Data for the request 255 let book_data = match to_data(&book) { 256 Ok(d) => d, 257 Err(e) => { 258 error.set(Some(format!("Failed to serialize notebook: {:?}", e))); 259 removing.set(false); 260 return; 261 } 262 }; 263 264 // Update the notebook record 265 let request = PutRecord::new() 266 .repo(AtIdentifier::Did(did)) 267 .collection(Nsid::new_static("sh.weaver.notebook.book").unwrap()) 268 .rkey(notebook_rkey.clone()) 269 .record(book_data) 270 .build(); 271 272 match client.send(request).await { 273 Ok(_) => { 274 show_remove_confirm.set(false); 275 // Notify parent to remove from local state 276 if let Some(handler) = &on_removed { 277 handler.call(()); 278 } 279 } 280 Err(e) => { 281 error.set(Some(format!("Failed to update notebook: {:?}", e))); 282 } 283 } 284 removing.set(false); 285 }); 286 }; 287 288 // Handler for pinning/unpinning 289 let entry_uri_for_pin = props.entry_uri.clone(); 290 let entry_cid_for_pin = props.entry_cid.clone(); 291 let is_currently_pinned = props.is_pinned; 292 let on_pinned_changed = props.on_pinned_changed.clone(); 293 let pin_fetcher = fetcher.clone(); 294 let handle_pin_toggle = move |_| { 295 let fetcher = pin_fetcher.clone(); 296 let entry_uri = entry_uri_for_pin.clone(); 297 let entry_cid = entry_cid_for_pin.clone(); 298 let on_pinned_changed = on_pinned_changed.clone(); 299 300 spawn(async move { 301 use jacquard::{from_data, prelude::*, to_data, types::string::Nsid}; 302 use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; 303 304 pinning.set(true); 305 error.set(None); 306 307 let client = fetcher.get_client(); 308 309 let did = match fetcher.current_did().await { 310 Some(d) => d, 311 None => { 312 error.set(Some("Not authenticated".to_string())); 313 pinning.set(false); 314 return; 315 } 316 }; 317 318 let profile_uri_str = format!("at://{}/sh.weaver.actor.profile/self", did); 319 320 // Try to fetch existing weaver profile 321 let weaver_uri = match WeaverProfile::uri(&profile_uri_str) { 322 Ok(u) => u, 323 Err(_) => { 324 error.set(Some("Invalid profile URI".to_string())); 325 pinning.set(false); 326 return; 327 } 328 }; 329 let existing_profile: Option<WeaverProfile<'static>> = 330 match client.fetch_record(&weaver_uri).await { 331 Ok(output) => Some(output.value), 332 Err(_) => None, 333 }; 334 335 // Build the new pinned list 336 let new_pinned: Vec<StrongRef<'static>> = if is_currently_pinned { 337 // Unpin: remove from list 338 existing_profile 339 .as_ref() 340 .and_then(|p| p.pinned.as_ref()) 341 .map(|pins| { 342 pins.iter() 343 .filter(|r| r.uri.as_ref() != entry_uri.as_ref()) 344 .cloned() 345 .collect() 346 }) 347 .unwrap_or_default() 348 } else { 349 // Pin: add to list 350 let new_ref = StrongRef::new() 351 .uri(entry_uri.clone().into_static()) 352 .cid(entry_cid.clone()) 353 .build(); 354 let mut pins = existing_profile 355 .as_ref() 356 .and_then(|p| p.pinned.clone()) 357 .unwrap_or_default(); 358 // Don't add if already exists 359 if !pins.iter().any(|r| r.uri.as_ref() == entry_uri.as_ref()) { 360 pins.push(new_ref); 361 } 362 pins 363 }; 364 365 // Build the profile to save 366 let profile_to_save = if let Some(existing) = existing_profile { 367 // Update existing profile 368 WeaverProfile { 369 pinned: Some(new_pinned), 370 ..existing 371 } 372 } else { 373 // Create new profile from bsky data 374 let bsky_uri_str = format!("at://{}/app.bsky.actor.profile/self", did); 375 let bsky_profile: Option<BskyProfile<'static>> = 376 match BskyProfile::uri(&bsky_uri_str) { 377 Ok(bsky_uri) => match client.fetch_record(&bsky_uri).await { 378 Ok(output) => Some(output.value), 379 Err(_) => None, 380 }, 381 Err(_) => None, 382 }; 383 384 WeaverProfile::new() 385 .maybe_display_name( 386 bsky_profile 387 .as_ref() 388 .and_then(|p| p.display_name.clone()), 389 ) 390 .maybe_description( 391 bsky_profile.as_ref().and_then(|p| p.description.clone()), 392 ) 393 .maybe_avatar(bsky_profile.as_ref().and_then(|p| p.avatar.clone())) 394 .maybe_banner(bsky_profile.as_ref().and_then(|p| p.banner.clone())) 395 .bluesky(true) 396 .created_at(jacquard::types::string::Datetime::now()) 397 .pinned(new_pinned) 398 .build() 399 }; 400 401 // Serialize and save 402 let profile_data = match to_data(&profile_to_save) { 403 Ok(d) => d, 404 Err(e) => { 405 error.set(Some(format!("Failed to serialize profile: {:?}", e))); 406 pinning.set(false); 407 return; 408 } 409 }; 410 411 let request = PutRecord::new() 412 .repo(AtIdentifier::Did(did)) 413 .collection(Nsid::new_static("sh.weaver.actor.profile").unwrap()) 414 .rkey(jacquard::types::string::Rkey::new("self").unwrap()) 415 .record(profile_data) 416 .build(); 417 418 match client.send(request).await { 419 Ok(_) => { 420 show_dropdown.set(false); 421 if let Some(handler) = &on_pinned_changed { 422 handler.call(!is_currently_pinned); 423 } 424 } 425 Err(e) => { 426 error.set(Some(format!("Failed to update profile: {:?}", e))); 427 } 428 } 429 pinning.set(false); 430 }); 431 }; 432 433 rsx! { 434 document::Link { rel: "stylesheet", href: ENTRY_ACTIONS_CSS } 435 436 div { class: "entry-actions", 437 // Edit button (always visible for owner) 438 AppLink { 439 to: edit_target, 440 class: Some("entry-action-link".to_string()), 441 Button { 442 variant: ButtonVariant::Ghost, 443 "Edit" 444 } 445 } 446 447 // Dropdown for destructive actions 448 div { class: "entry-actions-dropdown", 449 Button { 450 variant: ButtonVariant::Ghost, 451 onclick: move |_| show_dropdown.toggle(), 452 "" 453 } 454 455 if show_dropdown() { 456 div { class: "dropdown-menu", 457 // Pin/Unpin (first) 458 button { 459 class: "dropdown-item", 460 disabled: pinning(), 461 onclick: handle_pin_toggle, 462 if pinning() { 463 "Updating..." 464 } else if props.is_pinned { 465 "Unpin" 466 } else { 467 "Pin" 468 } 469 } 470 // Remove from notebook (if in notebook) 471 if props.in_notebook { 472 button { 473 class: "dropdown-item", 474 onclick: move |_| { 475 show_dropdown.set(false); 476 show_remove_confirm.set(true); 477 }, 478 "Remove from notebook" 479 } 480 } 481 // Delete (last, danger style) 482 button { 483 class: "dropdown-item dropdown-item-danger", 484 onclick: move |_| { 485 show_dropdown.set(false); 486 show_delete_confirm.set(true); 487 }, 488 "Delete" 489 } 490 } 491 } 492 } 493 494 // Delete confirmation dialog 495 DialogRoot { 496 open: show_delete_confirm(), 497 on_open_change: move |open: bool| show_delete_confirm.set(open), 498 DialogContent { 499 DialogTitle { "Delete Entry?" } 500 DialogDescription { 501 "Delete \"{entry_title}\"? This removes the published entry. You can restore from drafts if needed." 502 } 503 if let Some(ref err) = error() { 504 div { class: "dialog-error", "{err}" } 505 } 506 div { class: "dialog-actions", 507 Button { 508 variant: ButtonVariant::Destructive, 509 onclick: handle_delete, 510 disabled: deleting(), 511 if deleting() { "Deleting..." } else { "Delete" } 512 } 513 Button { 514 variant: ButtonVariant::Ghost, 515 onclick: move |_| show_delete_confirm.set(false), 516 "Cancel" 517 } 518 } 519 } 520 } 521 522 // Remove from notebook confirmation dialog 523 if props.in_notebook { 524 { 525 let entry_title_for_remove = entry_title.clone(); 526 rsx! { 527 DialogRoot { 528 open: show_remove_confirm(), 529 on_open_change: move |open: bool| show_remove_confirm.set(open), 530 DialogContent { 531 DialogTitle { "Remove from Notebook?" } 532 DialogDescription { 533 "Remove \"{entry_title_for_remove}\" from this notebook? The entry will still exist but won't be part of this notebook." 534 } 535 if let Some(ref err) = error() { 536 div { class: "dialog-error", "{err}" } 537 } 538 div { class: "dialog-actions", 539 Button { 540 variant: ButtonVariant::Primary, 541 onclick: handle_remove_from_notebook, 542 disabled: removing(), 543 if removing() { "Removing..." } else { "Remove" } 544 } 545 Button { 546 variant: ButtonVariant::Ghost, 547 onclick: move |_| show_remove_confirm.set(false), 548 "Cancel" 549 } 550 } 551 } 552 } 553 } 554 } 555 } 556 } 557 } 558}