pretty editor works (sans validation), incl file upload & blobref insertion

Orual c75de580 6ee392ce

+245 -9
+1 -1
crates/weaver-app/Cargo.toml
··· 60 60 chrono = { version = "0.4", features = ["wasmbind"] } 61 61 wasm-bindgen = "0.2" 62 62 wasm-bindgen-futures = "0.4" 63 - web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console"] } 63 + web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement"] } 64 64 js-sys = "0.3" 65 65 gloo-storage = "0.3" 66 66
+68 -2
crates/weaver-app/assets/styling/record-view.css
··· 94 94 } 95 95 96 96 .tab-button:hover { 97 - color: var(--color-text); 97 + color: var(--color-secondary); 98 + font-weight: 550; 99 + border-bottom-color: var(--color-secondary); 98 100 } 99 101 100 102 .tab-button.active { 101 - color: var(--color-text); 103 + color: var(--color-primary); 104 + font-weight: 600; 102 105 border-bottom-color: var(--color-primary); 103 106 } 104 107 ··· 172 175 padding-right: 1rem; 173 176 border-left: 2px solid var(--color-secondary); 174 177 border-bottom: 1px dashed var(--color-subtle); 178 + z-index: 1; 175 179 } 176 180 177 181 .field-label { ··· 272 276 .blob-image { 273 277 max-width: 600px; 274 278 max-height: 400px; 279 + width: auto; 280 + height: auto; 281 + object-fit: contain; 282 + display: block; 275 283 margin-top: 0.5rem; 276 284 margin-bottom: 0.5rem; 285 + align-self: flex-start; 277 286 } 278 287 279 288 .string-type-tag { ··· 645 654 646 655 .add-field-widget button:hover { 647 656 border: 1px solid var(--color-primary); 657 + background-color: var(--color-primary); 658 + color: var(--color-surface); 648 659 } 649 660 650 661 .add-field-widget button:disabled { ··· 662 673 .bytes-field input, 663 674 .bytes-field textarea { 664 675 min-width: 80ch; 676 + } 677 + 678 + /* Blob upload section */ 679 + .blob-upload-section { 680 + margin-top: 0.5rem; 681 + display: flex; 682 + align-items: center; 683 + gap: 0.5rem; 684 + flex-wrap: wrap; 685 + width: 100%; 686 + z-index: 2; 687 + } 688 + 689 + .blob-upload-section input[type="file"] { 690 + font-family: var(--font-mono); 691 + font-size: 0.85rem; 692 + color: var(--color-text); 693 + flex: 1 1 auto; 694 + min-width: 0; 695 + max-width: 100%; 696 + overflow: visible; 697 + text-overflow: clip; 698 + white-space: normal; 699 + } 700 + 701 + .blob-upload-section input[type="file"]::file-selector-button { 702 + font-family: var(--font-mono); 703 + font-size: 0.85rem; 704 + color: var(--color-text); 705 + background: var(--color-surface); 706 + border: 1px dashed var(--color-border); 707 + padding: 0.25rem 0.5rem; 708 + margin-right: 0.5rem; 709 + margin-bottom: -0.2rem; 710 + cursor: pointer; 711 + transition: 712 + background-color 0.2s, 713 + border-color 0.2s; 714 + } 715 + 716 + .blob-upload-section input[type="file"]::file-selector-button:hover { 717 + border: 1px solid var(--color-primary); 718 + background-color: var(--color-primary); 719 + color: var(--color-surface); 720 + } 721 + 722 + .blob-upload-section input[type="file"]:disabled::file-selector-button { 723 + opacity: 0.5; 724 + cursor: not-allowed; 725 + } 726 + 727 + .upload-status { 728 + font-size: 0.85rem; 729 + color: var(--color-subtle); 730 + font-style: italic; 665 731 } 666 732 667 733 .field-remove-button {
+176 -6
crates/weaver-app/src/views/record.rs
··· 3 3 use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; 4 4 use crate::fetch::CachedFetcher; 5 5 use dioxus::prelude::*; 6 - use dioxus_logger::tracing::*; 7 6 use humansize::format_size; 8 7 use jacquard::api::com_atproto::repo::get_record::GetRecordOutput; 9 8 use jacquard::client::AgentError; ··· 16 15 smol_str::SmolStr, 17 16 types::{aturi::AtUri, cid::Cid, ident::AtIdentifier, string::Nsid}, 18 17 }; 18 + use mime_sniffer::MimeTypeSniffer; 19 19 use weaver_api::com_atproto::repo::{ 20 20 create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord, 21 21 }; ··· 1177 1177 } 1178 1178 } 1179 1179 1180 - /// Blob field - shows CID, size (editable), mime type (read-only) 1180 + /// Blob field - shows CID, size (editable), mime type (read-only), file upload 1181 1181 #[component] 1182 1182 fn EditableBlobField( 1183 1183 root: Signal<Data<'static>>, ··· 1203 1203 let mut size_input = use_signal(|| String::new()); 1204 1204 let mut cid_error = use_signal(|| None::<String>); 1205 1205 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>); 1206 1209 1207 1210 // Sync inputs when blob data changes 1208 1211 use_effect(move || { ··· 1212 1215 } 1213 1216 }); 1214 1217 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 + 1215 1296 let path_for_cid = path.clone(); 1216 1297 let handle_cid_change = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| { 1217 1298 let text = evt.value(); ··· 1260 1341 .map(|(_, _, mime)| mime.starts_with("image/")) 1261 1342 .unwrap_or(false); 1262 1343 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 { 1264 1348 blob_data().map(|(cid, _, mime)| { 1265 1349 let format = mime.strip_prefix("image/").unwrap_or("jpeg"); 1266 1350 format!( ··· 1317 1401 class: "blob-image", 1318 1402 } 1319 1403 } 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}" } 1323 1416 } 1324 1417 } 1325 1418 } 1326 1419 } 1420 + } 1421 + } 1422 + 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 + }); 1327 1497 } 1328 1498 } 1329 1499