tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
26
fork
atom
overview
issues
2
pulls
pipelines
bytes + better cidlink handling
Orual
2 months ago
6ee392ce
735d9d74
+282
-25
4 changed files
expand all
collapse all
unified
split
Cargo.lock
crates
weaver-app
Cargo.toml
assets
styling
record-view.css
src
views
record.rs
+1
Cargo.lock
···
8654
version = "0.1.0"
8655
dependencies = [
8656
"axum",
0
8657
"chrono",
8658
"console_error_panic_hook",
8659
"dashmap",
···
8654
version = "0.1.0"
8655
dependencies = [
8656
"axum",
8657
+
"base64 0.22.1",
8658
"chrono",
8659
"console_error_panic_hook",
8660
"dashmap",
+1
crates/weaver-app/Cargo.toml
···
38
serde_json = "1.0"
39
hex_fmt = "0.3"
40
humansize = "2.0.0"
0
41
http = "1.3"
42
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
43
dioxus-free-icons = { version = "0.10.0" }
···
38
serde_json = "1.0"
39
hex_fmt = "0.3"
40
humansize = "2.0.0"
41
+
base64 = "0.22"
42
http = "1.3"
43
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
44
dioxus-free-icons = { version = "0.10.0" }
+18
-4
crates/weaver-app/assets/styling/record-view.css
···
259
flex-direction: column;
260
align-items: flex-start;
261
padding-right: 1rem;
0
262
}
263
264
.section-content .record-field {
···
471
}
472
473
.array-item .section-header {
474
-
margin-left: -1.52rem;
0
0
0
0
0
0
0
475
}
476
477
.array-length {
···
485
width: 100%;
486
display: flex;
487
flex-direction: column;
0
488
}
489
490
/* Pretty Editor Input Fields */
···
649
gap: 0.5rem;
650
}
651
0
0
0
0
0
0
652
.field-remove-button {
653
font-family: var(--font-mono);
654
font-size: 0.7rem;
655
-
color: var(--color-subtle);
656
background: transparent;
657
border: none;
658
padding: 0.2rem 0.4rem;
659
cursor: pointer;
660
-
margin-left: auto;
661
transition: color 0.2s;
662
}
663
664
.field-remove-button:hover {
665
-
color: var(--color-error, #ff6b6b);
666
}
667
668
/* Blob Field Styling */
···
259
flex-direction: column;
260
align-items: flex-start;
261
padding-right: 1rem;
262
+
margin-left: -0.02rem;
263
}
264
265
.section-content .record-field {
···
472
}
473
474
.array-item .section-header {
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;
483
}
484
485
.array-length {
···
493
width: 100%;
494
display: flex;
495
flex-direction: column;
496
+
margin-left: 1.49rem;
497
}
498
499
/* Pretty Editor Input Fields */
···
658
gap: 0.5rem;
659
}
660
661
+
/* Bytes fields need wider inputs */
662
+
.bytes-field input,
663
+
.bytes-field textarea {
664
+
min-width: 80ch;
665
+
}
666
+
667
.field-remove-button {
668
font-family: var(--font-mono);
669
font-size: 0.7rem;
670
+
color: var(--color-error);
671
background: transparent;
672
border: none;
673
padding: 0.2rem 0.4rem;
674
cursor: pointer;
0
675
transition: color 0.2s;
676
}
677
678
.field-remove-button:hover {
679
+
color: var(--color-warning, #ff6b6b);
680
}
681
682
/* Blob Field Styling */
+262
-21
crates/weaver-app/src/views/record.rs
···
892
.get_at_path(&path_for_memo)
893
.map(|d| d.clone().into_static())
894
{
895
-
Some(Data::Object(_)) => rsx! { EditableObjectField { root, path: path.clone(), did, remove_button } },
0
0
896
Some(Data::Array(_)) => rsx! { EditableArrayField { root, path: path.clone(), did } },
897
Some(Data::String(_)) => {
898
rsx! { EditableStringField { root, path: path.clone(), remove_button } }
···
904
rsx! { EditableBooleanField { root, path: path.clone(), remove_button } }
905
}
906
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 } }
0
0
0
912
}
913
914
None => rsx! { div { class: "field-error", "❌ Path not found: {path}" } },
···
981
let type_label = format!("{:?}", string_type()).to_lowercase();
982
let is_plain_string = string_type() == LexiconStringType::String;
983
0
0
0
0
0
0
0
0
0
0
0
0
0
984
rsx! {
985
div { class: "record-field",
986
div { class: "field-header",
···
1001
input {
1002
r#type: "text",
1003
value: "{input_text}",
0
1004
oninput: handle_input,
1005
class: if parse_error().is_some() { "invalid" } else { "" },
1006
}
···
1246
let format = mime.strip_prefix("image/").unwrap_or("jpeg");
1247
format!(
1248
"https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}",
1249
-
did,
1250
-
cid,
1251
-
format
1252
)
1253
})
1254
} else {
···
1310
}
1311
}
1312
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1313
// ============================================================================
1314
// Field with Remove Button Wrapper
1315
// ============================================================================
···
1417
}
1418
}
1419
div {
1420
-
class: "add-field-widget",
1421
-
button {
0
0
1422
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
-
}
0
1433
}
1434
1435
···
892
.get_at_path(&path_for_memo)
893
.map(|d| d.clone().into_static())
894
{
895
+
Some(Data::Object(_)) => {
896
+
rsx! { EditableObjectField { root, path: path.clone(), did, remove_button } }
897
+
}
898
Some(Data::Array(_)) => rsx! { EditableArrayField { root, path: path.clone(), did } },
899
Some(Data::String(_)) => {
900
rsx! { EditableStringField { root, path: path.clone(), remove_button } }
···
906
rsx! { EditableBooleanField { root, path: path.clone(), remove_button } }
907
}
908
Some(Data::Null) => rsx! { EditableNullField { root, path: path.clone(), remove_button } },
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 } }
917
}
918
919
None => rsx! { div { class: "field-error", "❌ Path not found: {path}" } },
···
986
let type_label = format!("{:?}", string_type()).to_lowercase();
987
let is_plain_string = string_type() == LexiconStringType::String;
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
+
1002
rsx! {
1003
div { class: "record-field",
1004
div { class: "field-header",
···
1019
input {
1020
r#type: "text",
1021
value: "{input_text}",
1022
+
style: "width: {input_width}",
1023
oninput: handle_input,
1024
class: if parse_error().is_some() { "invalid" } else { "" },
1025
}
···
1265
let format = mime.strip_prefix("image/").unwrap_or("jpeg");
1266
format!(
1267
"https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}",
1268
+
did, cid, format
0
0
1269
)
1270
})
1271
} else {
···
1327
}
1328
}
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
+
1551
// ============================================================================
1552
// Field with Remove Button Wrapper
1553
// ============================================================================
···
1655
}
1656
}
1657
div {
1658
+
class: "array-item",
1659
+
div {
1660
+
class: "add-field-widget",
1661
+
button {
1662
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
+
}
1674
}
1675
1676