bytes + better cidlink handling

Orual 6ee392ce 735d9d74

+282 -25
+1
Cargo.lock
··· 8654 8654 version = "0.1.0" 8655 8655 dependencies = [ 8656 8656 "axum", 8657 + "base64 0.22.1", 8657 8658 "chrono", 8658 8659 "console_error_panic_hook", 8659 8660 "dashmap",
+1
crates/weaver-app/Cargo.toml
··· 38 38 serde_json = "1.0" 39 39 hex_fmt = "0.3" 40 40 humansize = "2.0.0" 41 + base64 = "0.22" 41 42 http = "1.3" 42 43 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 43 44 dioxus-free-icons = { version = "0.10.0" }
+18 -4
crates/weaver-app/assets/styling/record-view.css
··· 259 259 flex-direction: column; 260 260 align-items: flex-start; 261 261 padding-right: 1rem; 262 + margin-left: -0.02rem; 262 263 } 263 264 264 265 .section-content .record-field { ··· 471 472 } 472 473 473 474 .array-item .section-header { 474 - margin-left: -1.52rem; 475 + margin-left: -1.53rem; 476 + } 477 + .array-item .section-content .record-field { 478 + margin-left: 0rem; 479 + } 480 + 481 + .array-item .record-field { 482 + margin-left: -1.5rem; 475 483 } 476 484 477 485 .array-length { ··· 485 493 width: 100%; 486 494 display: flex; 487 495 flex-direction: column; 496 + margin-left: 1.49rem; 488 497 } 489 498 490 499 /* Pretty Editor Input Fields */ ··· 649 658 gap: 0.5rem; 650 659 } 651 660 661 + /* Bytes fields need wider inputs */ 662 + .bytes-field input, 663 + .bytes-field textarea { 664 + min-width: 80ch; 665 + } 666 + 652 667 .field-remove-button { 653 668 font-family: var(--font-mono); 654 669 font-size: 0.7rem; 655 - color: var(--color-subtle); 670 + color: var(--color-error); 656 671 background: transparent; 657 672 border: none; 658 673 padding: 0.2rem 0.4rem; 659 674 cursor: pointer; 660 - margin-left: auto; 661 675 transition: color 0.2s; 662 676 } 663 677 664 678 .field-remove-button:hover { 665 - color: var(--color-error, #ff6b6b); 679 + color: var(--color-warning, #ff6b6b); 666 680 } 667 681 668 682 /* Blob Field Styling */
+262 -21
crates/weaver-app/src/views/record.rs
··· 892 892 .get_at_path(&path_for_memo) 893 893 .map(|d| d.clone().into_static()) 894 894 { 895 - Some(Data::Object(_)) => rsx! { EditableObjectField { root, path: path.clone(), did, remove_button } }, 895 + Some(Data::Object(_)) => { 896 + rsx! { EditableObjectField { root, path: path.clone(), did, remove_button } } 897 + } 896 898 Some(Data::Array(_)) => rsx! { EditableArrayField { root, path: path.clone(), did } }, 897 899 Some(Data::String(_)) => { 898 900 rsx! { EditableStringField { root, path: path.clone(), remove_button } } ··· 904 906 rsx! { EditableBooleanField { root, path: path.clone(), remove_button } } 905 907 } 906 908 Some(Data::Null) => rsx! { EditableNullField { root, path: path.clone(), remove_button } }, 907 - Some(Data::Blob(_)) => rsx! { EditableBlobField { root, path: path.clone(), did, remove_button } }, 908 - 909 - // Not yet implemented - fall back to read-only DataView 910 - Some(data) => { 911 - rsx! { DataView { data: data.clone(), path: path.clone(), did } } 909 + Some(Data::Blob(_)) => { 910 + rsx! { EditableBlobField { root, path: path.clone(), did, remove_button } } 911 + } 912 + Some(Data::Bytes(_)) => { 913 + rsx! { EditableBytesField { root, path: path.clone(), remove_button } } 914 + } 915 + Some(Data::CidLink(_)) => { 916 + rsx! { EditableCidLinkField { root, path: path.clone(), remove_button } } 912 917 } 913 918 914 919 None => rsx! { div { class: "field-error", "❌ Path not found: {path}" } }, ··· 981 986 let type_label = format!("{:?}", string_type()).to_lowercase(); 982 987 let is_plain_string = string_type() == LexiconStringType::String; 983 988 989 + // Dynamic width based on content length 990 + let input_width = use_memo(move || { 991 + let len = input_text().len(); 992 + let min_width = match string_type() { 993 + LexiconStringType::Cid => 60, 994 + LexiconStringType::Nsid => 40, 995 + LexiconStringType::Did => 50, 996 + LexiconStringType::AtUri => 50, 997 + _ => 20, 998 + }; 999 + format!("{}ch", len.max(min_width)) 1000 + }); 1001 + 984 1002 rsx! { 985 1003 div { class: "record-field", 986 1004 div { class: "field-header", ··· 1001 1019 input { 1002 1020 r#type: "text", 1003 1021 value: "{input_text}", 1022 + style: "width: {input_width}", 1004 1023 oninput: handle_input, 1005 1024 class: if parse_error().is_some() { "invalid" } else { "" }, 1006 1025 } ··· 1246 1265 let format = mime.strip_prefix("image/").unwrap_or("jpeg"); 1247 1266 format!( 1248 1267 "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 1249 - did, 1250 - cid, 1251 - format 1268 + did, cid, format 1252 1269 ) 1253 1270 }) 1254 1271 } else { ··· 1310 1327 } 1311 1328 } 1312 1329 1330 + /// Bytes field with hex/base64 auto-detection 1331 + #[component] 1332 + fn EditableBytesField( 1333 + root: Signal<Data<'static>>, 1334 + path: String, 1335 + #[props(default)] remove_button: Option<Element>, 1336 + ) -> Element { 1337 + let path_for_memo = path.clone(); 1338 + let current_bytes = use_memo(move || { 1339 + root.read() 1340 + .get_at_path(&path_for_memo) 1341 + .and_then(|d| match d { 1342 + Data::Bytes(b) => Some(bytes_to_hex(b)), 1343 + _ => None, 1344 + }) 1345 + }); 1346 + 1347 + let mut input_text = use_signal(|| String::new()); 1348 + let mut parse_error = use_signal(|| None::<String>); 1349 + let mut detected_format = use_signal(|| None::<String>); 1350 + 1351 + // Sync input when bytes change 1352 + use_effect(move || { 1353 + if let Some(hex) = current_bytes() { 1354 + input_text.set(hex); 1355 + } 1356 + }); 1357 + 1358 + let path_for_mutation = path.clone(); 1359 + let handle_input = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| { 1360 + let text = evt.value(); 1361 + input_text.set(text.clone()); 1362 + 1363 + match parse_bytes_input(&text) { 1364 + Ok((bytes, format)) => { 1365 + parse_error.set(None); 1366 + detected_format.set(Some(format)); 1367 + root.with_mut(|data| { 1368 + if let Some(target) = data.get_at_path_mut(&path_for_mutation) { 1369 + *target = Data::Bytes(bytes); 1370 + } 1371 + }); 1372 + } 1373 + Err(e) => { 1374 + parse_error.set(Some(e)); 1375 + detected_format.set(None); 1376 + } 1377 + } 1378 + }; 1379 + 1380 + let byte_count = current_bytes() 1381 + .map(|hex| hex.chars().filter(|c| c.is_ascii_hexdigit()).count() / 2) 1382 + .unwrap_or(0); 1383 + let size_label = if byte_count > 128 { 1384 + format_size(byte_count, humansize::BINARY) 1385 + } else { 1386 + format!("{} bytes", byte_count) 1387 + }; 1388 + 1389 + rsx! { 1390 + div { class: "record-field bytes-field", 1391 + div { class: "field-header", 1392 + PathLabel { path: path.clone() } 1393 + span { class: "string-type-tag", " [bytes: {size_label}]" } 1394 + if let Some(format) = detected_format() { 1395 + span { class: "bytes-format-tag", " ({format})" } 1396 + } 1397 + {remove_button} 1398 + } 1399 + textarea { 1400 + value: "{input_text}", 1401 + placeholder: "Paste hex (1a2b3c...) or base64 (YWJj...)", 1402 + oninput: handle_input, 1403 + class: if parse_error().is_some() { "invalid" } else { "" }, 1404 + rows: "3", 1405 + } 1406 + if let Some(err) = parse_error() { 1407 + span { class: "field-error", " ❌ {err}" } 1408 + } 1409 + } 1410 + } 1411 + } 1412 + 1413 + /// Parse bytes from hex or base64, auto-detecting format 1414 + fn parse_bytes_input(text: &str) -> Result<(jacquard::bytes::Bytes, String), String> { 1415 + let trimmed = text.trim(); 1416 + if trimmed.is_empty() { 1417 + return Err("Input is empty".to_string()); 1418 + } 1419 + 1420 + // Remove common whitespace/separators 1421 + let cleaned: String = trimmed 1422 + .chars() 1423 + .filter(|c| !c.is_whitespace() && *c != ':' && *c != '-') 1424 + .collect(); 1425 + 1426 + // Try hex first (more restrictive) 1427 + if cleaned.chars().all(|c| c.is_ascii_hexdigit()) { 1428 + parse_hex_bytes(&cleaned).map(|b| (b, "hex".to_string())) 1429 + } else { 1430 + // Try base64 1431 + parse_base64_bytes(&cleaned).map(|b| (b, "base64".to_string())) 1432 + } 1433 + } 1434 + 1435 + /// Parse hex string to bytes 1436 + fn parse_hex_bytes(hex: &str) -> Result<jacquard::bytes::Bytes, String> { 1437 + if hex.len() % 2 != 0 { 1438 + return Err("Hex string must have even length".to_string()); 1439 + } 1440 + 1441 + let mut bytes = Vec::with_capacity(hex.len() / 2); 1442 + for chunk in hex.as_bytes().chunks(2) { 1443 + let hex_byte = std::str::from_utf8(chunk).map_err(|e| format!("Invalid UTF-8: {}", e))?; 1444 + let byte = 1445 + u8::from_str_radix(hex_byte, 16).map_err(|e| format!("Invalid hex digit: {}", e))?; 1446 + bytes.push(byte); 1447 + } 1448 + 1449 + Ok(jacquard::bytes::Bytes::from(bytes)) 1450 + } 1451 + 1452 + /// Parse base64 string to bytes 1453 + fn parse_base64_bytes(b64: &str) -> Result<jacquard::bytes::Bytes, String> { 1454 + use base64::Engine; 1455 + let engine = base64::engine::general_purpose::STANDARD; 1456 + 1457 + engine 1458 + .decode(b64) 1459 + .map(jacquard::bytes::Bytes::from) 1460 + .map_err(|e| format!("Invalid base64: {}", e)) 1461 + } 1462 + 1463 + /// Convert bytes to hex display string (with spacing every 4 chars) 1464 + fn bytes_to_hex(bytes: &jacquard::bytes::Bytes) -> String { 1465 + bytes 1466 + .iter() 1467 + .enumerate() 1468 + .map(|(i, b)| { 1469 + let hex = format!("{:02x}", b); 1470 + if i > 0 && i % 2 == 0 { 1471 + format!(" {}", hex) 1472 + } else { 1473 + hex 1474 + } 1475 + }) 1476 + .collect() 1477 + } 1478 + 1479 + /// CidLink field with validation 1480 + #[component] 1481 + fn EditableCidLinkField( 1482 + root: Signal<Data<'static>>, 1483 + path: String, 1484 + #[props(default)] remove_button: Option<Element>, 1485 + ) -> Element { 1486 + let path_for_memo = path.clone(); 1487 + let current_cid = use_memo(move || { 1488 + root.read() 1489 + .get_at_path(&path_for_memo) 1490 + .map(|d| match d { 1491 + Data::CidLink(cid) => cid.to_string(), 1492 + _ => String::new(), 1493 + }) 1494 + .unwrap_or_default() 1495 + }); 1496 + 1497 + let mut input_text = use_signal(|| String::new()); 1498 + let mut parse_error = use_signal(|| None::<String>); 1499 + 1500 + use_effect(move || { 1501 + input_text.set(current_cid()); 1502 + }); 1503 + 1504 + let input_width = use_memo(move || { 1505 + let len = input_text().len(); 1506 + format!("{}ch", len.max(60)) 1507 + }); 1508 + 1509 + let path_for_mutation = path.clone(); 1510 + let handle_input = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| { 1511 + let text = evt.value(); 1512 + input_text.set(text.clone()); 1513 + 1514 + match jacquard::types::cid::Cid::new_owned(text.as_bytes()) { 1515 + Ok(new_cid) => { 1516 + parse_error.set(None); 1517 + root.with_mut(|data| { 1518 + if let Some(target) = data.get_at_path_mut(&path_for_mutation) { 1519 + *target = Data::CidLink(new_cid); 1520 + } 1521 + }); 1522 + } 1523 + Err(_) => { 1524 + parse_error.set(Some("Invalid CID format".to_string())); 1525 + } 1526 + } 1527 + }; 1528 + 1529 + rsx! { 1530 + div { class: "record-field cidlink-field", 1531 + div { class: "field-header", 1532 + PathLabel { path: path.clone() } 1533 + span { class: "string-type-tag", " [cid-link]" } 1534 + {remove_button} 1535 + } 1536 + input { 1537 + r#type: "text", 1538 + value: "{input_text}", 1539 + style: "width: {input_width}", 1540 + placeholder: "bafyrei...", 1541 + oninput: handle_input, 1542 + class: if parse_error().is_some() { "invalid" } else { "" }, 1543 + } 1544 + if let Some(err) = parse_error() { 1545 + span { class: "field-error", " ❌ {err}" } 1546 + } 1547 + } 1548 + } 1549 + } 1550 + 1313 1551 // ============================================================================ 1314 1552 // Field with Remove Button Wrapper 1315 1553 // ============================================================================ ··· 1417 1655 } 1418 1656 } 1419 1657 div { 1420 - class: "add-field-widget", 1421 - button { 1658 + class: "array-item", 1659 + div { 1660 + class: "add-field-widget", 1661 + button { 1422 1662 1423 - onclick: move |_| { 1424 - root.with_mut(|data| { 1425 - if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_add) { 1426 - let new_item = create_array_item_default(arr); 1427 - arr.0.push(new_item); 1428 - } 1429 - }); 1430 - }, 1431 - "+ Add Item" 1432 - } 1663 + onclick: move |_| { 1664 + root.with_mut(|data| { 1665 + if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_add) { 1666 + let new_item = create_array_item_default(arr); 1667 + arr.0.push(new_item); 1668 + } 1669 + }); 1670 + }, 1671 + "+ Add Item" 1672 + } 1673 + } 1433 1674 } 1434 1675 1435 1676