@@ -3,7 +3,6 @@ use crate::auth::AuthState;
33use crate :: components:: dialog:: { DialogContent , DialogDescription , DialogRoot , DialogTitle } ;
44use crate :: fetch:: CachedFetcher ;
55use dioxus:: prelude:: * ;
6- use dioxus_logger:: tracing:: * ;
76use humansize:: format_size;
87use jacquard:: api:: com_atproto:: repo:: get_record:: GetRecordOutput ;
98use jacquard:: client:: AgentError ;
@@ -16,6 +15,7 @@ use jacquard::{
1615 smol_str:: SmolStr ,
1716 types:: { aturi:: AtUri , cid:: Cid , ident:: AtIdentifier , string:: Nsid } ,
1817} ;
18+ use mime_sniffer:: MimeTypeSniffer ;
1919use weaver_api:: com_atproto:: repo:: {
2020 create_record:: CreateRecord , delete_record:: DeleteRecord , put_record:: PutRecord ,
2121} ;
@@ -1177,7 +1177,7 @@ fn EditableNullField(
11771177 }
11781178}
11791179
1180- /// Blob field - shows CID, size (editable), mime type (read-only)
1180+ /// Blob field - shows CID, size (editable), mime type (read-only), file upload
11811181#[ component]
11821182fn EditableBlobField (
11831183 root : Signal < Data < ' static > > ,
@@ -1203,6 +1203,9 @@ fn EditableBlobField(
12031203 let mut size_input = use_signal ( || String :: new ( ) ) ;
12041204 let mut cid_error = use_signal ( || None :: < String > ) ;
12051205 let mut size_error = use_signal ( || None :: < String > ) ;
1206+ let mut uploading = use_signal ( || false ) ;
1207+ let mut upload_error = use_signal ( || None :: < String > ) ;
1208+ let mut preview_data_url = use_signal ( || None :: < String > ) ;
12061209
12071210 // Sync inputs when blob data changes
12081211 use_effect ( move || {
@@ -1212,6 +1215,84 @@ fn EditableBlobField(
12121215 }
12131216 } ) ;
12141217
1218+ let fetcher = use_context :: < CachedFetcher > ( ) ;
1219+ let path_for_upload = path. clone ( ) ;
1220+ let handle_file = move |evt : dioxus:: prelude:: Event < dioxus:: prelude:: FormData > | {
1221+ let fetcher = fetcher. clone ( ) ;
1222+ let path_upload_clone = path_for_upload. clone ( ) ;
1223+ spawn ( async move {
1224+ uploading. set ( true ) ;
1225+ upload_error. set ( None ) ;
1226+
1227+ let files = evt. files ( ) ;
1228+ for file_data in files {
1229+ match file_data. read_bytes ( ) . await {
1230+ Ok ( bytes_data) => {
1231+ // Convert to jacquard Bytes and sniff MIME type
1232+ let bytes = jacquard:: bytes:: Bytes :: from ( bytes_data. to_vec ( ) ) ;
1233+ let mime_str = bytes
1234+ . sniff_mime_type ( )
1235+ . unwrap_or ( "application/octet-stream" ) ;
1236+ let mime_type = jacquard:: types:: blob:: MimeType :: new_owned ( mime_str) ;
1237+
1238+ // Create data URL for immediate preview if it's an image
1239+ if mime_str. starts_with ( "image/" ) {
1240+ let base64_data = base64:: Engine :: encode (
1241+ & base64:: engine:: general_purpose:: STANDARD ,
1242+ & bytes,
1243+ ) ;
1244+ let data_url = format ! ( "data:{};base64,{}" , mime_str, base64_data) ;
1245+ preview_data_url. set ( Some ( data_url. clone ( ) ) ) ;
1246+
1247+ // Try to decode dimensions and populate aspectRatio field
1248+ #[ cfg( target_arch = "wasm32" ) ]
1249+ {
1250+ let path_clone = path_upload_clone. clone ( ) ;
1251+ spawn ( async move {
1252+ if let Some ( ( width, height) ) =
1253+ decode_image_dimensions ( & data_url) . await
1254+ {
1255+ populate_aspect_ratio (
1256+ root,
1257+ & path_clone,
1258+ width as i64 ,
1259+ height as i64 ,
1260+ ) ;
1261+ }
1262+ } ) ;
1263+ }
1264+ }
1265+
1266+ // Upload blob
1267+ let client = fetcher. get_client ( ) ;
1268+ match client. upload_blob ( bytes, mime_type) . await {
1269+ Ok ( new_blob) => {
1270+ // Update blob in record
1271+ let path_ref = path_upload_clone. clone ( ) ;
1272+ root. with_mut ( |record_data| {
1273+ if let Some ( Data :: Blob ( blob) ) =
1274+ record_data. get_at_path_mut ( & path_ref)
1275+ {
1276+ * blob = new_blob;
1277+ }
1278+ } ) ;
1279+ upload_error. set ( None ) ;
1280+ }
1281+ Err ( e) => {
1282+ upload_error. set ( Some ( format ! ( "Upload failed: {:?}" , e) ) ) ;
1283+ }
1284+ }
1285+ }
1286+ Err ( e) => {
1287+ upload_error. set ( Some ( format ! ( "Failed to read file: {}" , e) ) ) ;
1288+ }
1289+ }
1290+ }
1291+
1292+ uploading. set ( false ) ;
1293+ } ) ;
1294+ } ;
1295+
12151296 let path_for_cid = path. clone ( ) ;
12161297 let handle_cid_change = move |evt : dioxus:: prelude:: Event < dioxus:: prelude:: FormData > | {
12171298 let text = evt. value ( ) ;
@@ -1260,7 +1341,10 @@ fn EditableBlobField(
12601341 . map ( |( _, _, mime) | mime. starts_with ( "image/" ) )
12611342 . unwrap_or ( false ) ;
12621343
1263- let image_url = if !is_placeholder && is_image {
1344+ // Use preview data URL if available (fresh upload), otherwise CDN
1345+ let image_url = if let Some ( data_url) = preview_data_url ( ) {
1346+ Some ( data_url)
1347+ } else if !is_placeholder && is_image {
12641348 blob_data ( ) . map ( |( cid, _, mime) | {
12651349 let format = mime. strip_prefix ( "image/" ) . unwrap_or ( "jpeg" ) ;
12661350 format ! (
@@ -1317,16 +1401,102 @@ fn EditableBlobField(
13171401 class: "blob-image" ,
13181402 }
13191403 }
1320- if is_placeholder {
1321- div { class: "muted blob-upload-note" ,
1322- "File upload coming soon"
1404+ div { class: "blob-upload-section" ,
1405+ input {
1406+ r#type: "file" ,
1407+ accept: if is_image { "image/*" } else { "*/*" } ,
1408+ onchange: handle_file,
1409+ disabled: uploading( ) ,
1410+ }
1411+ if uploading( ) {
1412+ span { class: "upload-status" , "Uploading..." }
1413+ }
1414+ if let Some ( err) = upload_error( ) {
1415+ div { class: "field-error" , "❌ {err}" }
13231416 }
13241417 }
13251418 }
13261419 }
13271420 }
13281421}
13291422
1423+ /// Decode image dimensions from data URL using browser Image API
1424+ #[ cfg( target_arch = "wasm32" ) ]
1425+ async fn decode_image_dimensions ( data_url : & str ) -> Option < ( u32 , u32 ) > {
1426+ use wasm_bindgen:: JsCast ;
1427+ use wasm_bindgen:: prelude:: * ;
1428+ use wasm_bindgen_futures:: JsFuture ;
1429+
1430+ let window = web_sys:: window ( ) ?;
1431+ let document = window. document ( ) ?;
1432+
1433+ let img = document. create_element ( "img" ) . ok ( ) ?;
1434+ let img = img. dyn_into :: < web_sys:: HtmlImageElement > ( ) . ok ( ) ?;
1435+
1436+ img. set_src ( data_url) ;
1437+
1438+ // Wait for image to load
1439+ let promise = js_sys:: Promise :: new ( & mut |resolve, _reject| {
1440+ let onload = Closure :: wrap ( Box :: new ( move || {
1441+ resolve. call0 ( & JsValue :: NULL ) . ok ( ) ;
1442+ } ) as Box < dyn FnMut ( ) > ) ;
1443+
1444+ img. set_onload ( Some ( onload. as_ref ( ) . unchecked_ref ( ) ) ) ;
1445+ onload. forget ( ) ;
1446+ } ) ;
1447+
1448+ JsFuture :: from ( promise) . await . ok ( ) ?;
1449+
1450+ Some ( ( img. natural_width ( ) , img. natural_height ( ) ) )
1451+ }
1452+
1453+ /// Find and populate aspectRatio field for a blob
1454+ #[ allow( unused) ]
1455+ fn populate_aspect_ratio (
1456+ mut root : Signal < Data < ' static > > ,
1457+ blob_path : & str ,
1458+ width : i64 ,
1459+ height : i64 ,
1460+ ) {
1461+ // Query for all aspectRatio fields and collect the path we want
1462+ let aspect_path_to_update = {
1463+ let data = root. read ( ) ;
1464+ let query_result = data. query ( "...aspectRatio" ) ;
1465+
1466+ query_result. multiple ( ) . and_then ( |matches| {
1467+ // Find aspectRatio that's a sibling of our blob
1468+ // e.g. blob at "embed.images[0].image" -> look for "embed.images[0].aspectRatio"
1469+ let blob_parent = blob_path. rsplit_once ( '.' ) . map ( |( parent, _) | parent) ;
1470+
1471+ matches. iter ( ) . find_map ( |query_match| {
1472+ let aspect_path = query_match. path . as_str ( ) ;
1473+ let aspect_parent = aspect_path. rsplit_once ( '.' ) . map ( |( parent, _) | parent) ;
1474+
1475+ // Check if they share the same parent
1476+ if blob_parent == aspect_parent {
1477+ Some ( aspect_path. to_string ( ) )
1478+ } else {
1479+ None
1480+ }
1481+ } )
1482+ } )
1483+ } ;
1484+
1485+ // Update the aspectRatio if we found a matching field
1486+ if let Some ( aspect_path) = aspect_path_to_update {
1487+ use jacquard:: types:: value:: Object ;
1488+ use std:: collections:: BTreeMap ;
1489+
1490+ let mut aspect_obj = BTreeMap :: new ( ) ;
1491+ aspect_obj. insert ( "width" . into ( ) , Data :: Integer ( width) ) ;
1492+ aspect_obj. insert ( "height" . into ( ) , Data :: Integer ( height) ) ;
1493+
1494+ root. with_mut ( |record_data| {
1495+ record_data. set_at_path ( & aspect_path, Data :: Object ( Object ( aspect_obj) ) ) ;
1496+ } ) ;
1497+ }
1498+ }
1499+
13301500/// Bytes field with hex/base64 auto-detection
13311501#[ component]
13321502fn EditableBytesField (
0 commit comments