atproto blogging
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 "",
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!("", 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}