diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index d8e1d0a..6677ef4 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -60,7 +60,7 @@ mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d0 chrono = { version = "0.4", features = ["wasmbind"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console"] } +web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement"] } js-sys = "0.3" gloo-storage = "0.3" diff --git a/crates/weaver-app/assets/styling/record-view.css b/crates/weaver-app/assets/styling/record-view.css index 5306a48..1c340ee 100644 --- a/crates/weaver-app/assets/styling/record-view.css +++ b/crates/weaver-app/assets/styling/record-view.css @@ -94,11 +94,14 @@ } .tab-button:hover { - color: var(--color-text); + color: var(--color-secondary); + font-weight: 550; + border-bottom-color: var(--color-secondary); } .tab-button.active { - color: var(--color-text); + color: var(--color-primary); + font-weight: 600; border-bottom-color: var(--color-primary); } @@ -172,6 +175,7 @@ padding-right: 1rem; border-left: 2px solid var(--color-secondary); border-bottom: 1px dashed var(--color-subtle); + z-index: 1; } .field-label { @@ -272,8 +276,13 @@ .blob-image { max-width: 600px; max-height: 400px; + width: auto; + height: auto; + object-fit: contain; + display: block; margin-top: 0.5rem; margin-bottom: 0.5rem; + align-self: flex-start; } .string-type-tag { @@ -645,6 +654,8 @@ .add-field-widget button:hover { border: 1px solid var(--color-primary); + background-color: var(--color-primary); + color: var(--color-surface); } .add-field-widget button:disabled { @@ -664,6 +675,61 @@ min-width: 80ch; } +/* Blob upload section */ +.blob-upload-section { + margin-top: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + width: 100%; + z-index: 2; +} + +.blob-upload-section input[type="file"] { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--color-text); + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + overflow: visible; + text-overflow: clip; + white-space: normal; +} + +.blob-upload-section input[type="file"]::file-selector-button { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--color-text); + background: var(--color-surface); + border: 1px dashed var(--color-border); + padding: 0.25rem 0.5rem; + margin-right: 0.5rem; + margin-bottom: -0.2rem; + cursor: pointer; + transition: + background-color 0.2s, + border-color 0.2s; +} + +.blob-upload-section input[type="file"]::file-selector-button:hover { + border: 1px solid var(--color-primary); + background-color: var(--color-primary); + color: var(--color-surface); +} + +.blob-upload-section input[type="file"]:disabled::file-selector-button { + opacity: 0.5; + cursor: not-allowed; +} + +.upload-status { + font-size: 0.85rem; + color: var(--color-subtle); + font-style: italic; +} + .field-remove-button { font-family: var(--font-mono); font-size: 0.7rem; diff --git a/crates/weaver-app/src/views/record.rs b/crates/weaver-app/src/views/record.rs index 79ec307..bc2c99d 100644 --- a/crates/weaver-app/src/views/record.rs +++ b/crates/weaver-app/src/views/record.rs @@ -3,7 +3,6 @@ use crate::auth::AuthState; use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; use crate::fetch::CachedFetcher; use dioxus::prelude::*; -use dioxus_logger::tracing::*; use humansize::format_size; use jacquard::api::com_atproto::repo::get_record::GetRecordOutput; use jacquard::client::AgentError; @@ -16,6 +15,7 @@ use jacquard::{ smol_str::SmolStr, types::{aturi::AtUri, cid::Cid, ident::AtIdentifier, string::Nsid}, }; +use mime_sniffer::MimeTypeSniffer; use weaver_api::com_atproto::repo::{ create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord, }; @@ -1177,7 +1177,7 @@ fn EditableNullField( } } -/// Blob field - shows CID, size (editable), mime type (read-only) +/// Blob field - shows CID, size (editable), mime type (read-only), file upload #[component] fn EditableBlobField( root: Signal>, @@ -1203,6 +1203,9 @@ fn EditableBlobField( let mut size_input = use_signal(|| String::new()); let mut cid_error = use_signal(|| None::); let mut size_error = use_signal(|| None::); + let mut uploading = use_signal(|| false); + let mut upload_error = use_signal(|| None::); + let mut preview_data_url = use_signal(|| None::); // Sync inputs when blob data changes use_effect(move || { @@ -1212,6 +1215,84 @@ fn EditableBlobField( } }); + let fetcher = use_context::(); + let path_for_upload = path.clone(); + let handle_file = move |evt: dioxus::prelude::Event| { + let fetcher = fetcher.clone(); + let path_upload_clone = path_for_upload.clone(); + spawn(async move { + uploading.set(true); + upload_error.set(None); + + let files = evt.files(); + for file_data in files { + match file_data.read_bytes().await { + Ok(bytes_data) => { + // Convert to jacquard Bytes and sniff MIME type + let bytes = jacquard::bytes::Bytes::from(bytes_data.to_vec()); + let mime_str = bytes + .sniff_mime_type() + .unwrap_or("application/octet-stream"); + let mime_type = jacquard::types::blob::MimeType::new_owned(mime_str); + + // Create data URL for immediate preview if it's an image + if mime_str.starts_with("image/") { + let base64_data = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &bytes, + ); + let data_url = format!("data:{};base64,{}", mime_str, base64_data); + preview_data_url.set(Some(data_url.clone())); + + // Try to decode dimensions and populate aspectRatio field + #[cfg(target_arch = "wasm32")] + { + let path_clone = path_upload_clone.clone(); + spawn(async move { + if let Some((width, height)) = + decode_image_dimensions(&data_url).await + { + populate_aspect_ratio( + root, + &path_clone, + width as i64, + height as i64, + ); + } + }); + } + } + + // Upload blob + let client = fetcher.get_client(); + match client.upload_blob(bytes, mime_type).await { + Ok(new_blob) => { + // Update blob in record + let path_ref = path_upload_clone.clone(); + root.with_mut(|record_data| { + if let Some(Data::Blob(blob)) = + record_data.get_at_path_mut(&path_ref) + { + *blob = new_blob; + } + }); + upload_error.set(None); + } + Err(e) => { + upload_error.set(Some(format!("Upload failed: {:?}", e))); + } + } + } + Err(e) => { + upload_error.set(Some(format!("Failed to read file: {}", e))); + } + } + } + + uploading.set(false); + }); + }; + let path_for_cid = path.clone(); let handle_cid_change = move |evt: dioxus::prelude::Event| { let text = evt.value(); @@ -1260,7 +1341,10 @@ fn EditableBlobField( .map(|(_, _, mime)| mime.starts_with("image/")) .unwrap_or(false); - let image_url = if !is_placeholder && is_image { + // Use preview data URL if available (fresh upload), otherwise CDN + let image_url = if let Some(data_url) = preview_data_url() { + Some(data_url) + } else if !is_placeholder && is_image { blob_data().map(|(cid, _, mime)| { let format = mime.strip_prefix("image/").unwrap_or("jpeg"); format!( @@ -1317,9 +1401,18 @@ fn EditableBlobField( class: "blob-image", } } - if is_placeholder { - div { class: "muted blob-upload-note", - "File upload coming soon" + div { class: "blob-upload-section", + input { + r#type: "file", + accept: if is_image { "image/*" } else { "*/*" }, + onchange: handle_file, + disabled: uploading(), + } + if uploading() { + span { class: "upload-status", "Uploading..." } + } + if let Some(err) = upload_error() { + div { class: "field-error", "❌ {err}" } } } } @@ -1327,6 +1420,83 @@ fn EditableBlobField( } } +/// Decode image dimensions from data URL using browser Image API +#[cfg(target_arch = "wasm32")] +async fn decode_image_dimensions(data_url: &str) -> Option<(u32, u32)> { + use wasm_bindgen::JsCast; + use wasm_bindgen::prelude::*; + use wasm_bindgen_futures::JsFuture; + + let window = web_sys::window()?; + let document = window.document()?; + + let img = document.create_element("img").ok()?; + let img = img.dyn_into::().ok()?; + + img.set_src(data_url); + + // Wait for image to load + let promise = js_sys::Promise::new(&mut |resolve, _reject| { + let onload = Closure::wrap(Box::new(move || { + resolve.call0(&JsValue::NULL).ok(); + }) as Box); + + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + }); + + JsFuture::from(promise).await.ok()?; + + Some((img.natural_width(), img.natural_height())) +} + +/// Find and populate aspectRatio field for a blob +#[allow(unused)] +fn populate_aspect_ratio( + mut root: Signal>, + blob_path: &str, + width: i64, + height: i64, +) { + // Query for all aspectRatio fields and collect the path we want + let aspect_path_to_update = { + let data = root.read(); + let query_result = data.query("...aspectRatio"); + + query_result.multiple().and_then(|matches| { + // Find aspectRatio that's a sibling of our blob + // e.g. blob at "embed.images[0].image" -> look for "embed.images[0].aspectRatio" + let blob_parent = blob_path.rsplit_once('.').map(|(parent, _)| parent); + + matches.iter().find_map(|query_match| { + let aspect_path = query_match.path.as_str(); + let aspect_parent = aspect_path.rsplit_once('.').map(|(parent, _)| parent); + + // Check if they share the same parent + if blob_parent == aspect_parent { + Some(aspect_path.to_string()) + } else { + None + } + }) + }) + }; + + // Update the aspectRatio if we found a matching field + if let Some(aspect_path) = aspect_path_to_update { + use jacquard::types::value::Object; + use std::collections::BTreeMap; + + let mut aspect_obj = BTreeMap::new(); + aspect_obj.insert("width".into(), Data::Integer(width)); + aspect_obj.insert("height".into(), Data::Integer(height)); + + root.with_mut(|record_data| { + record_data.set_at_path(&aspect_path, Data::Object(Object(aspect_obj))); + }); + } +} + /// Bytes field with hex/base64 auto-detection #[component] fn EditableBytesField(