Skip to content

Commit c75de58

Browse files
committed
pretty editor works (sans validation), incl file upload & blobref insertion
1 parent 6ee392c commit c75de58

3 files changed

Lines changed: 245 additions & 9 deletions

File tree

crates/weaver-app/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d0
6060
chrono = { version = "0.4", features = ["wasmbind"] }
6161
wasm-bindgen = "0.2"
6262
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"] }
6464
js-sys = "0.3"
6565
gloo-storage = "0.3"
6666

crates/weaver-app/assets/styling/record-view.css

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,14 @@
9494
}
9595

9696
.tab-button:hover {
97-
color: var(--color-text);
97+
color: var(--color-secondary);
98+
font-weight: 550;
99+
border-bottom-color: var(--color-secondary);
98100
}
99101

100102
.tab-button.active {
101-
color: var(--color-text);
103+
color: var(--color-primary);
104+
font-weight: 600;
102105
border-bottom-color: var(--color-primary);
103106
}
104107

@@ -172,6 +175,7 @@
172175
padding-right: 1rem;
173176
border-left: 2px solid var(--color-secondary);
174177
border-bottom: 1px dashed var(--color-subtle);
178+
z-index: 1;
175179
}
176180

177181
.field-label {
@@ -272,8 +276,13 @@
272276
.blob-image {
273277
max-width: 600px;
274278
max-height: 400px;
279+
width: auto;
280+
height: auto;
281+
object-fit: contain;
282+
display: block;
275283
margin-top: 0.5rem;
276284
margin-bottom: 0.5rem;
285+
align-self: flex-start;
277286
}
278287

279288
.string-type-tag {
@@ -645,6 +654,8 @@
645654

646655
.add-field-widget button:hover {
647656
border: 1px solid var(--color-primary);
657+
background-color: var(--color-primary);
658+
color: var(--color-surface);
648659
}
649660

650661
.add-field-widget button:disabled {
@@ -664,6 +675,61 @@
664675
min-width: 80ch;
665676
}
666677

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;
731+
}
732+
667733
.field-remove-button {
668734
font-family: var(--font-mono);
669735
font-size: 0.7rem;

crates/weaver-app/src/views/record.rs

Lines changed: 176 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ use crate::auth::AuthState;
33
use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle};
44
use crate::fetch::CachedFetcher;
55
use dioxus::prelude::*;
6-
use dioxus_logger::tracing::*;
76
use humansize::format_size;
87
use jacquard::api::com_atproto::repo::get_record::GetRecordOutput;
98
use 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;
1919
use 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]
11821182
fn 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]
13321502
fn EditableBytesField(

0 commit comments

Comments
 (0)