at main 345 lines 13 kB view raw
1//! Action buttons for notebooks (pin/unpin, delete, settings). 2 3use crate::auth::AuthState; 4use crate::components::button::{Button, ButtonVariant}; 5use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; 6use crate::components::notebook::NotebookSettingsPanel; 7use crate::fetch::Fetcher; 8use dioxus::prelude::*; 9use jacquard::types::aturi::AtUri; 10use jacquard::types::ident::AtIdentifier; 11use jacquard::types::string::Cid; 12use jacquard::IntoStatic; 13use weaver_api::com_atproto::repo::put_record::PutRecord; 14use weaver_api::com_atproto::repo::strong_ref::StrongRef; 15use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile; 16use weaver_api::sh_weaver::notebook::book::Book; 17 18/// Action buttons for a notebook: pin/unpin, delete, settings. 19#[component] 20pub fn NotebookActions( 21 notebook_uri: AtUri<'static>, 22 notebook_cid: Cid<'static>, 23 notebook_title: String, 24 /// The Book record for populating settings form. 25 notebook: Book<'static>, 26 #[props(default = false)] is_pinned: bool, 27 #[props(default)] on_deleted: Option<EventHandler<()>>, 28 #[props(default)] on_pinned_changed: Option<EventHandler<bool>>, 29 #[props(default)] on_settings_saved: Option<EventHandler<()>>, 30) -> Element { 31 let auth_state = use_context::<Signal<AuthState>>(); 32 let fetcher = use_context::<Fetcher>(); 33 34 let mut show_delete_confirm = use_signal(|| false); 35 let mut show_settings = use_signal(|| false); 36 let mut show_dropdown = use_signal(|| false); 37 let mut deleting = use_signal(|| false); 38 let mut pinning = use_signal(|| false); 39 let mut error = use_signal(|| None::<String>); 40 41 // Check ownership - compare auth DID with notebook's authority 42 let current_did = auth_state.read().did.clone(); 43 let notebook_authority = notebook_uri.authority(); 44 let is_owner = match (&current_did, notebook_authority) { 45 (Some(current), AtIdentifier::Did(notebook_did)) => *current == *notebook_did, 46 _ => false, 47 }; 48 49 if !is_owner { 50 return rsx! {}; 51 } 52 53 let notebook_uri_for_delete = notebook_uri.clone(); 54 let title_for_display = notebook_title.clone(); 55 let on_deleted_handler = on_deleted.clone(); 56 57 let delete_fetcher = fetcher.clone(); 58 let handle_delete = move |_| { 59 let fetcher = delete_fetcher.clone(); 60 let uri = notebook_uri_for_delete.clone(); 61 let on_deleted = on_deleted_handler.clone(); 62 63 spawn(async move { 64 use jacquard::client::AgentSessionExt; 65 66 deleting.set(true); 67 error.set(None); 68 69 let rkey = match uri.rkey() { 70 Some(r) => r.clone().into_static(), 71 None => { 72 error.set(Some("Invalid notebook URI".to_string())); 73 deleting.set(false); 74 return; 75 } 76 }; 77 78 let client = fetcher.get_client(); 79 match client.delete_record::<Book>(rkey).await { 80 Ok(_) => { 81 show_delete_confirm.set(false); 82 if let Some(handler) = &on_deleted { 83 handler.call(()); 84 } 85 } 86 Err(e) => { 87 error.set(Some(format!("Delete failed: {:?}", e))); 88 } 89 } 90 deleting.set(false); 91 }); 92 }; 93 94 // Handler for pinning/unpinning 95 let notebook_uri_for_pin = notebook_uri.clone(); 96 let notebook_cid_for_pin = notebook_cid.clone(); 97 let is_currently_pinned = is_pinned; 98 let on_pinned_changed_handler = on_pinned_changed.clone(); 99 let pin_fetcher = fetcher.clone(); 100 let handle_pin_toggle = move |_| { 101 let fetcher = pin_fetcher.clone(); 102 let notebook_uri = notebook_uri_for_pin.clone(); 103 let notebook_cid = notebook_cid_for_pin.clone(); 104 let on_pinned_changed = on_pinned_changed_handler.clone(); 105 106 spawn(async move { 107 use jacquard::{from_data, prelude::*, to_data, types::string::Nsid}; 108 use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; 109 110 pinning.set(true); 111 error.set(None); 112 113 let client = fetcher.get_client(); 114 115 let did = match fetcher.current_did().await { 116 Some(d) => d, 117 None => { 118 error.set(Some("Not authenticated".to_string())); 119 pinning.set(false); 120 return; 121 } 122 }; 123 124 let profile_uri_str = format!("at://{}/sh.weaver.actor.profile/self", did); 125 126 // Try to fetch existing weaver profile 127 let weaver_uri = match WeaverProfile::uri(&profile_uri_str) { 128 Ok(u) => u, 129 Err(_) => { 130 error.set(Some("Invalid profile URI".to_string())); 131 pinning.set(false); 132 return; 133 } 134 }; 135 let existing_profile: Option<WeaverProfile<'static>> = 136 match client.fetch_record(&weaver_uri).await { 137 Ok(output) => Some(output.value), 138 Err(_) => None, 139 }; 140 141 // Build the new pinned list 142 let new_pinned: Vec<StrongRef<'static>> = if is_currently_pinned { 143 // Unpin: remove from list 144 existing_profile 145 .as_ref() 146 .and_then(|p| p.pinned.as_ref()) 147 .map(|pins| { 148 pins.iter() 149 .filter(|r| r.uri.as_ref() != notebook_uri.as_ref()) 150 .cloned() 151 .collect() 152 }) 153 .unwrap_or_default() 154 } else { 155 // Pin: add to list 156 let new_ref = StrongRef::new() 157 .uri(notebook_uri.clone().into_static()) 158 .cid(notebook_cid.clone()) 159 .build(); 160 let mut pins = existing_profile 161 .as_ref() 162 .and_then(|p| p.pinned.clone()) 163 .unwrap_or_default(); 164 // Don't add if already exists 165 if !pins.iter().any(|r| r.uri.as_ref() == notebook_uri.as_ref()) { 166 pins.push(new_ref); 167 } 168 pins 169 }; 170 171 // Build the profile to save 172 let profile_to_save = if let Some(existing) = existing_profile { 173 // Update existing profile 174 WeaverProfile { 175 pinned: Some(new_pinned), 176 ..existing 177 } 178 } else { 179 // Create new profile from bsky data 180 let bsky_uri_str = format!("at://{}/app.bsky.actor.profile/self", did); 181 let bsky_profile: Option<BskyProfile<'static>> = 182 match BskyProfile::uri(&bsky_uri_str) { 183 Ok(bsky_uri) => match client.fetch_record(&bsky_uri).await { 184 Ok(output) => Some(output.value), 185 Err(_) => None, 186 }, 187 Err(_) => None, 188 }; 189 190 WeaverProfile::new() 191 .maybe_display_name( 192 bsky_profile 193 .as_ref() 194 .and_then(|p| p.display_name.clone()), 195 ) 196 .maybe_description( 197 bsky_profile.as_ref().and_then(|p| p.description.clone()), 198 ) 199 .maybe_avatar(bsky_profile.as_ref().and_then(|p| p.avatar.clone())) 200 .maybe_banner(bsky_profile.as_ref().and_then(|p| p.banner.clone())) 201 .bluesky(true) 202 .created_at(jacquard::types::string::Datetime::now()) 203 .pinned(new_pinned) 204 .build() 205 }; 206 207 // Serialize and save 208 let profile_data = match to_data(&profile_to_save) { 209 Ok(d) => d, 210 Err(e) => { 211 error.set(Some(format!("Failed to serialize profile: {:?}", e))); 212 pinning.set(false); 213 return; 214 } 215 }; 216 217 let request = PutRecord::new() 218 .repo(AtIdentifier::Did(did)) 219 .collection(Nsid::new_static("sh.weaver.actor.profile").unwrap()) 220 .rkey(jacquard::types::string::Rkey::new("self").unwrap()) 221 .record(profile_data) 222 .build(); 223 224 match client.send(request).await { 225 Ok(_) => { 226 show_dropdown.set(false); 227 if let Some(handler) = &on_pinned_changed { 228 handler.call(!is_currently_pinned); 229 } 230 } 231 Err(e) => { 232 error.set(Some(format!("Failed to update profile: {:?}", e))); 233 } 234 } 235 pinning.set(false); 236 }); 237 }; 238 239 // Settings handlers. 240 let on_settings_saved_handler = on_settings_saved.clone(); 241 let handle_settings_saved = move |_| { 242 show_settings.set(false); 243 if let Some(handler) = &on_settings_saved_handler { 244 handler.call(()); 245 } 246 }; 247 248 let handle_settings_cancel = move |_| { 249 show_settings.set(false); 250 }; 251 252 let handle_open_settings = move |_| { 253 show_dropdown.set(false); 254 show_settings.set(true); 255 }; 256 257 rsx! { 258 div { class: "notebook-actions", 259 // Dropdown for actions 260 div { class: "notebook-actions-dropdown", 261 Button { 262 variant: ButtonVariant::Ghost, 263 onclick: move |_| show_dropdown.toggle(), 264 "" 265 } 266 267 if show_dropdown() { 268 div { class: "dropdown-menu", 269 // Pin/Unpin (first) 270 button { 271 class: "dropdown-item", 272 disabled: pinning(), 273 onclick: handle_pin_toggle, 274 if pinning() { 275 "Updating..." 276 } else if is_pinned { 277 "Unpin" 278 } else { 279 "Pin" 280 } 281 } 282 // Settings 283 button { 284 class: "dropdown-item", 285 onclick: handle_open_settings, 286 "Settings" 287 } 288 // Delete (danger style) 289 button { 290 class: "dropdown-item dropdown-item-danger", 291 onclick: move |_| { 292 show_dropdown.set(false); 293 show_delete_confirm.set(true); 294 }, 295 "Delete" 296 } 297 } 298 } 299 } 300 301 // Delete confirmation dialog 302 DialogRoot { 303 open: show_delete_confirm(), 304 on_open_change: move |open: bool| show_delete_confirm.set(open), 305 DialogContent { 306 DialogTitle { "Delete Notebook?" } 307 DialogDescription { 308 "Delete \"{title_for_display}\"? The entries will remain but will no longer be part of this notebook." 309 } 310 if let Some(ref err) = error() { 311 div { class: "dialog-error", "{err}" } 312 } 313 div { class: "dialog-actions", 314 Button { 315 variant: ButtonVariant::Destructive, 316 onclick: handle_delete, 317 disabled: deleting(), 318 if deleting() { "Deleting..." } else { "Delete" } 319 } 320 Button { 321 variant: ButtonVariant::Ghost, 322 onclick: move |_| show_delete_confirm.set(false), 323 "Cancel" 324 } 325 } 326 } 327 } 328 329 // Settings dialog 330 DialogRoot { 331 open: show_settings(), 332 on_open_change: move |open: bool| show_settings.set(open), 333 DialogContent { 334 DialogTitle { "Notebook Settings" } 335 NotebookSettingsPanel { 336 notebook_uri: notebook_uri.clone(), 337 book: notebook.clone(), 338 on_saved: handle_settings_saved, 339 on_cancel: handle_settings_cancel, 340 } 341 } 342 } 343 } 344 } 345}