at main 361 lines 12 kB view raw
1//! Image upload component for the markdown editor. 2//! 3//! Provides file picker and upload functionality for adding images to entries. 4//! Shows a preview dialog with alt text input before confirming the upload. 5 6use base64::{Engine, engine::general_purpose::STANDARD}; 7use dioxus::prelude::*; 8use jacquard::IntoStatic; 9use jacquard::cowstr::ToCowStr; 10use jacquard::types::ident::AtIdentifier; 11use jacquard::{bytes::Bytes, types::tid::Tid}; 12use mime_sniffer::MimeTypeSniffer; 13 14use super::document::SignalEditorDocument; 15use crate::auth::AuthState; 16use crate::fetch::Fetcher; 17use weaver_api::sh_weaver::embed::images::Image; 18use weaver_editor_core::{EditorDocument, EditorImageResolver}; 19 20use crate::components::{ 21 button::{Button, ButtonVariant}, 22 dialog::{DialogContent, DialogRoot, DialogTitle}, 23}; 24 25/// Result of an image upload operation. 26#[derive(Clone, Debug)] 27pub struct UploadedImage { 28 /// The filename (used as the markdown reference name) 29 pub name: String, 30 /// Alt text for accessibility 31 pub alt: String, 32 /// MIME type of the image (sniffed from bytes) 33 pub mime_type: String, 34 /// Raw image bytes 35 pub data: Bytes, 36} 37 38/// Pending image data before user confirms with alt text. 39#[derive(Clone, Default)] 40struct PendingImage { 41 name: String, 42 mime_type: String, 43 data: Bytes, 44 data_url: String, 45} 46 47/// Props for the ImageUploadButton component. 48#[derive(Props, Clone, PartialEq)] 49pub struct ImageUploadButtonProps { 50 /// Callback when an image is selected and confirmed with alt text 51 pub on_image_selected: EventHandler<UploadedImage>, 52 /// Whether the button is disabled 53 #[props(default = false)] 54 pub disabled: bool, 55} 56 57/// A button that opens a file picker for image selection. 58/// 59/// When a file is selected, shows a preview dialog with alt text input. 60/// Only triggers the callback after user confirms. 61#[component] 62pub fn ImageUploadButton(props: ImageUploadButtonProps) -> Element { 63 let mut show_dialog = use_signal(|| false); 64 let mut pending_image = use_signal(PendingImage::default); 65 let mut alt_text = use_signal(String::new); 66 67 let on_file_change = move |evt: Event<FormData>| { 68 spawn(async move { 69 let files = evt.files(); 70 if let Some(file) = files.first() { 71 let name = file.name(); 72 73 if let Ok(data) = file.read_bytes().await { 74 let bytes = Bytes::from(data); 75 let mime_type = bytes 76 .sniff_mime_type() 77 .unwrap_or("application/octet-stream") 78 .to_string(); 79 80 let data_url = format!("data:{};base64,{}", mime_type, STANDARD.encode(&bytes)); 81 82 pending_image.set(PendingImage { 83 name: name.clone(), 84 mime_type, 85 data: bytes, 86 data_url, 87 }); 88 alt_text.set(String::new()); 89 show_dialog.set(true); 90 } 91 } 92 }); 93 }; 94 95 let on_image_selected = props.on_image_selected.clone(); 96 let confirm_upload = move |_| { 97 let pending = pending_image(); 98 let uploaded = UploadedImage { 99 name: pending.name, 100 alt: alt_text(), 101 mime_type: pending.mime_type, 102 data: pending.data, 103 }; 104 on_image_selected.call(uploaded); 105 show_dialog.set(false); 106 pending_image.set(PendingImage::default()); 107 alt_text.set(String::new()); 108 }; 109 110 let cancel_upload = move |_| { 111 show_dialog.set(false); 112 pending_image.set(PendingImage::default()); 113 alt_text.set(String::new()); 114 }; 115 116 rsx! { 117 label { 118 class: "toolbar-button", 119 title: "Image", 120 aria_label: "Add image", 121 input { 122 r#type: "file", 123 accept: "image/*", 124 style: "display: none;", 125 disabled: props.disabled, 126 onchange: on_file_change, 127 } 128 "🖼" 129 } 130 131 DialogRoot { 132 open: show_dialog(), 133 on_open_change: move |v| show_dialog.set(v), 134 135 DialogContent { 136 button { 137 class: "dialog-close", 138 r#type: "button", 139 aria_label: "Close", 140 tabindex: if show_dialog() { "0" } else { "-1" }, 141 onclick: cancel_upload, 142 "×" 143 } 144 145 DialogTitle { "Add Image" } 146 147 div { class: "image-preview-container", 148 img { 149 class: "image-preview", 150 src: "{pending_image().data_url}", 151 alt: "Preview", 152 } 153 } 154 155 div { class: "image-alt-input-container", 156 label { 157 r#for: "image-alt-text", 158 "Alt text" 159 } 160 textarea { 161 id: "image-alt-text", 162 class: "image-alt-input", 163 placeholder: "Describe this image for people who can't see it", 164 value: "{alt_text}", 165 oninput: move |e| alt_text.set(e.value()), 166 rows: "3", 167 } 168 } 169 170 div { class: "dialog-actions", 171 Button { 172 r#type: "button", 173 onclick: cancel_upload, 174 variant: ButtonVariant::Secondary, 175 "Cancel" 176 } 177 Button { 178 r#type: "button", 179 onclick: confirm_upload, 180 "Add Image" 181 } 182 } 183 } 184 } 185 } 186} 187 188/// Handle an uploaded image: add to resolver, insert markdown, and upload to PDS. 189/// 190/// This is the main handler for when an image is confirmed via the upload dialog. 191/// It: 192/// 1. Creates a data URL for immediate preview 193/// 2. Adds to the image resolver for display 194/// 3. Inserts markdown image syntax at cursor 195/// 4. If authenticated, uploads to PDS in background 196#[allow(clippy::too_many_arguments)] 197pub fn handle_image_upload( 198 uploaded: UploadedImage, 199 doc: &mut SignalEditorDocument, 200 image_resolver: &mut Signal<EditorImageResolver>, 201 auth_state: &Signal<AuthState>, 202 fetcher: &Fetcher, 203) { 204 // Build data URL for immediate preview. 205 let data_url = format!( 206 "data:{};base64,{}", 207 uploaded.mime_type, 208 STANDARD.encode(&uploaded.data) 209 ); 210 211 // Add to resolver for immediate display. 212 let name = uploaded.name.clone(); 213 image_resolver.with_mut(|resolver| { 214 resolver.add_pending(name.clone(), data_url); 215 }); 216 217 // Insert markdown image syntax at cursor. 218 let alt_text = if uploaded.alt.is_empty() { 219 name.clone() 220 } else { 221 uploaded.alt.clone() 222 }; 223 224 // Check if authenticated and get DID for draft path. 225 let auth = auth_state.read(); 226 let did_for_path = auth.did.clone(); 227 let is_authenticated = auth.is_authenticated(); 228 drop(auth); 229 230 // Pre-generate TID for the blob rkey (used in draft path and upload). 231 let blob_tid = jacquard::types::tid::Ticker::new().next(None); 232 233 // Build markdown with proper draft path if authenticated. 234 let markdown = if let Some(ref did) = did_for_path { 235 format!( 236 "![{}](/image/{}/draft/{}/{})", 237 alt_text, 238 did, 239 blob_tid.as_str(), 240 name 241 ) 242 } else { 243 // Fallback for unauthenticated - simple path (won't be publishable anyway). 244 format!("![{}](/image/{})", alt_text, name) 245 }; 246 247 let pos = doc.cursor_offset(); 248 doc.insert(pos, &markdown); 249 250 // Upload to PDS in background if authenticated. 251 if is_authenticated { 252 let fetcher = fetcher.clone(); 253 let name_for_upload = name.clone(); 254 let alt_for_upload = alt_text.clone(); 255 let data = uploaded.data.clone(); 256 let mut doc_for_spawn = doc.clone(); 257 let mut resolver_for_spawn = *image_resolver; 258 259 spawn(async move { 260 upload_image_to_pds( 261 &fetcher, 262 &mut doc_for_spawn, 263 &mut resolver_for_spawn, 264 data, 265 name_for_upload, 266 alt_for_upload, 267 blob_tid, 268 ) 269 .await; 270 }); 271 } else { 272 tracing::debug!(name = %name, "Image added with data URL (not authenticated)"); 273 } 274} 275 276/// Upload image to PDS and update resolver. 277async fn upload_image_to_pds( 278 fetcher: &Fetcher, 279 doc: &mut SignalEditorDocument, 280 image_resolver: &mut Signal<EditorImageResolver>, 281 data: Bytes, 282 name: String, 283 alt: String, 284 blob_tid: Tid, 285) { 286 let client = fetcher.get_client(); 287 use weaver_common::WeaverExt; 288 289 // Clone data for cache pre-warming. 290 #[cfg(feature = "fullstack-server")] 291 let data_for_cache = data.clone(); 292 293 // Use pre-generated TID as rkey for the blob record. 294 let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str()) 295 .expect("TID is valid record key"); 296 297 // Upload blob and create temporary PublishedBlob record. 298 match client.publish_blob(data, &name, Some(rkey)).await { 299 Ok((strong_ref, published_blob)) => { 300 // Get DID from fetcher. 301 let did = match fetcher.current_did().await { 302 Some(d) => d, 303 None => { 304 tracing::warn!("No DID available"); 305 return; 306 } 307 }; 308 309 // Extract rkey from the AT-URI. 310 let blob_rkey = match strong_ref.uri.rkey() { 311 Some(rkey) => rkey.0.clone().into_static(), 312 None => { 313 tracing::warn!("No rkey in PublishedBlob URI"); 314 return; 315 } 316 }; 317 318 let cid = published_blob.upload.blob().cid().clone().into_static(); 319 320 let name_for_resolver = name.clone(); 321 let image = Image::new() 322 .alt(alt.to_cowstr()) 323 .image(published_blob.upload) 324 .name(name.to_cowstr()) 325 .build(); 326 doc.add_image(&image, Some(&strong_ref.uri)); 327 328 // Promote from pending to uploaded in resolver. 329 let ident = AtIdentifier::Did(did); 330 image_resolver.with_mut(|resolver| { 331 resolver.promote_to_uploaded(&name_for_resolver, blob_rkey, ident); 332 }); 333 334 tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); 335 336 // Pre-warm server cache with blob bytes. 337 #[cfg(feature = "fullstack-server")] 338 { 339 use jacquard::smol_str::ToSmolStr; 340 if let Err(e) = crate::data::cache_blob_bytes( 341 cid.to_smolstr(), 342 Some(name_for_resolver.into()), 343 None, 344 data_for_cache.into(), 345 ) 346 .await 347 { 348 tracing::warn!(error = %e, "Failed to pre-warm blob cache"); 349 } 350 } 351 352 // Suppress unused variable warning when fullstack-server is disabled. 353 #[cfg(not(feature = "fullstack-server"))] 354 let _ = cid; 355 } 356 Err(e) => { 357 tracing::error!(error = %e, "Failed to upload image"); 358 // Image stays as data URL - will work for preview but not publish. 359 } 360 } 361}