tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
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
8654
version = "0.1.0"
8655
8655
dependencies = [
8656
8656
"axum",
8657
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
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
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
474
-
margin-left: -1.52rem;
475
475
+
margin-left: -1.53rem;
476
476
+
}
477
477
+
.array-item .section-content .record-field {
478
478
+
margin-left: 0rem;
479
479
+
}
480
480
+
481
481
+
.array-item .record-field {
482
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
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
661
+
/* Bytes fields need wider inputs */
662
662
+
.bytes-field input,
663
663
+
.bytes-field textarea {
664
664
+
min-width: 80ch;
665
665
+
}
666
666
+
652
667
.field-remove-button {
653
668
font-family: var(--font-mono);
654
669
font-size: 0.7rem;
655
655
-
color: var(--color-subtle);
670
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
660
-
margin-left: auto;
661
675
transition: color 0.2s;
662
676
}
663
677
664
678
.field-remove-button:hover {
665
665
-
color: var(--color-error, #ff6b6b);
679
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
895
-
Some(Data::Object(_)) => rsx! { EditableObjectField { root, path: path.clone(), did, remove_button } },
895
895
+
Some(Data::Object(_)) => {
896
896
+
rsx! { EditableObjectField { root, path: path.clone(), did, remove_button } }
897
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
907
-
Some(Data::Blob(_)) => rsx! { EditableBlobField { root, path: path.clone(), did, remove_button } },
908
908
-
909
909
-
// Not yet implemented - fall back to read-only DataView
910
910
-
Some(data) => {
911
911
-
rsx! { DataView { data: data.clone(), path: path.clone(), did } }
909
909
+
Some(Data::Blob(_)) => {
910
910
+
rsx! { EditableBlobField { root, path: path.clone(), did, remove_button } }
911
911
+
}
912
912
+
Some(Data::Bytes(_)) => {
913
913
+
rsx! { EditableBytesField { root, path: path.clone(), remove_button } }
914
914
+
}
915
915
+
Some(Data::CidLink(_)) => {
916
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
989
+
// Dynamic width based on content length
990
990
+
let input_width = use_memo(move || {
991
991
+
let len = input_text().len();
992
992
+
let min_width = match string_type() {
993
993
+
LexiconStringType::Cid => 60,
994
994
+
LexiconStringType::Nsid => 40,
995
995
+
LexiconStringType::Did => 50,
996
996
+
LexiconStringType::AtUri => 50,
997
997
+
_ => 20,
998
998
+
};
999
999
+
format!("{}ch", len.max(min_width))
1000
1000
+
});
1001
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
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
1249
-
did,
1250
1250
-
cid,
1251
1251
-
format
1268
1268
+
did, cid, format
1252
1269
)
1253
1270
})
1254
1271
} else {
···
1310
1327
}
1311
1328
}
1312
1329
1330
1330
+
/// Bytes field with hex/base64 auto-detection
1331
1331
+
#[component]
1332
1332
+
fn EditableBytesField(
1333
1333
+
root: Signal<Data<'static>>,
1334
1334
+
path: String,
1335
1335
+
#[props(default)] remove_button: Option<Element>,
1336
1336
+
) -> Element {
1337
1337
+
let path_for_memo = path.clone();
1338
1338
+
let current_bytes = use_memo(move || {
1339
1339
+
root.read()
1340
1340
+
.get_at_path(&path_for_memo)
1341
1341
+
.and_then(|d| match d {
1342
1342
+
Data::Bytes(b) => Some(bytes_to_hex(b)),
1343
1343
+
_ => None,
1344
1344
+
})
1345
1345
+
});
1346
1346
+
1347
1347
+
let mut input_text = use_signal(|| String::new());
1348
1348
+
let mut parse_error = use_signal(|| None::<String>);
1349
1349
+
let mut detected_format = use_signal(|| None::<String>);
1350
1350
+
1351
1351
+
// Sync input when bytes change
1352
1352
+
use_effect(move || {
1353
1353
+
if let Some(hex) = current_bytes() {
1354
1354
+
input_text.set(hex);
1355
1355
+
}
1356
1356
+
});
1357
1357
+
1358
1358
+
let path_for_mutation = path.clone();
1359
1359
+
let handle_input = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| {
1360
1360
+
let text = evt.value();
1361
1361
+
input_text.set(text.clone());
1362
1362
+
1363
1363
+
match parse_bytes_input(&text) {
1364
1364
+
Ok((bytes, format)) => {
1365
1365
+
parse_error.set(None);
1366
1366
+
detected_format.set(Some(format));
1367
1367
+
root.with_mut(|data| {
1368
1368
+
if let Some(target) = data.get_at_path_mut(&path_for_mutation) {
1369
1369
+
*target = Data::Bytes(bytes);
1370
1370
+
}
1371
1371
+
});
1372
1372
+
}
1373
1373
+
Err(e) => {
1374
1374
+
parse_error.set(Some(e));
1375
1375
+
detected_format.set(None);
1376
1376
+
}
1377
1377
+
}
1378
1378
+
};
1379
1379
+
1380
1380
+
let byte_count = current_bytes()
1381
1381
+
.map(|hex| hex.chars().filter(|c| c.is_ascii_hexdigit()).count() / 2)
1382
1382
+
.unwrap_or(0);
1383
1383
+
let size_label = if byte_count > 128 {
1384
1384
+
format_size(byte_count, humansize::BINARY)
1385
1385
+
} else {
1386
1386
+
format!("{} bytes", byte_count)
1387
1387
+
};
1388
1388
+
1389
1389
+
rsx! {
1390
1390
+
div { class: "record-field bytes-field",
1391
1391
+
div { class: "field-header",
1392
1392
+
PathLabel { path: path.clone() }
1393
1393
+
span { class: "string-type-tag", " [bytes: {size_label}]" }
1394
1394
+
if let Some(format) = detected_format() {
1395
1395
+
span { class: "bytes-format-tag", " ({format})" }
1396
1396
+
}
1397
1397
+
{remove_button}
1398
1398
+
}
1399
1399
+
textarea {
1400
1400
+
value: "{input_text}",
1401
1401
+
placeholder: "Paste hex (1a2b3c...) or base64 (YWJj...)",
1402
1402
+
oninput: handle_input,
1403
1403
+
class: if parse_error().is_some() { "invalid" } else { "" },
1404
1404
+
rows: "3",
1405
1405
+
}
1406
1406
+
if let Some(err) = parse_error() {
1407
1407
+
span { class: "field-error", " ❌ {err}" }
1408
1408
+
}
1409
1409
+
}
1410
1410
+
}
1411
1411
+
}
1412
1412
+
1413
1413
+
/// Parse bytes from hex or base64, auto-detecting format
1414
1414
+
fn parse_bytes_input(text: &str) -> Result<(jacquard::bytes::Bytes, String), String> {
1415
1415
+
let trimmed = text.trim();
1416
1416
+
if trimmed.is_empty() {
1417
1417
+
return Err("Input is empty".to_string());
1418
1418
+
}
1419
1419
+
1420
1420
+
// Remove common whitespace/separators
1421
1421
+
let cleaned: String = trimmed
1422
1422
+
.chars()
1423
1423
+
.filter(|c| !c.is_whitespace() && *c != ':' && *c != '-')
1424
1424
+
.collect();
1425
1425
+
1426
1426
+
// Try hex first (more restrictive)
1427
1427
+
if cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
1428
1428
+
parse_hex_bytes(&cleaned).map(|b| (b, "hex".to_string()))
1429
1429
+
} else {
1430
1430
+
// Try base64
1431
1431
+
parse_base64_bytes(&cleaned).map(|b| (b, "base64".to_string()))
1432
1432
+
}
1433
1433
+
}
1434
1434
+
1435
1435
+
/// Parse hex string to bytes
1436
1436
+
fn parse_hex_bytes(hex: &str) -> Result<jacquard::bytes::Bytes, String> {
1437
1437
+
if hex.len() % 2 != 0 {
1438
1438
+
return Err("Hex string must have even length".to_string());
1439
1439
+
}
1440
1440
+
1441
1441
+
let mut bytes = Vec::with_capacity(hex.len() / 2);
1442
1442
+
for chunk in hex.as_bytes().chunks(2) {
1443
1443
+
let hex_byte = std::str::from_utf8(chunk).map_err(|e| format!("Invalid UTF-8: {}", e))?;
1444
1444
+
let byte =
1445
1445
+
u8::from_str_radix(hex_byte, 16).map_err(|e| format!("Invalid hex digit: {}", e))?;
1446
1446
+
bytes.push(byte);
1447
1447
+
}
1448
1448
+
1449
1449
+
Ok(jacquard::bytes::Bytes::from(bytes))
1450
1450
+
}
1451
1451
+
1452
1452
+
/// Parse base64 string to bytes
1453
1453
+
fn parse_base64_bytes(b64: &str) -> Result<jacquard::bytes::Bytes, String> {
1454
1454
+
use base64::Engine;
1455
1455
+
let engine = base64::engine::general_purpose::STANDARD;
1456
1456
+
1457
1457
+
engine
1458
1458
+
.decode(b64)
1459
1459
+
.map(jacquard::bytes::Bytes::from)
1460
1460
+
.map_err(|e| format!("Invalid base64: {}", e))
1461
1461
+
}
1462
1462
+
1463
1463
+
/// Convert bytes to hex display string (with spacing every 4 chars)
1464
1464
+
fn bytes_to_hex(bytes: &jacquard::bytes::Bytes) -> String {
1465
1465
+
bytes
1466
1466
+
.iter()
1467
1467
+
.enumerate()
1468
1468
+
.map(|(i, b)| {
1469
1469
+
let hex = format!("{:02x}", b);
1470
1470
+
if i > 0 && i % 2 == 0 {
1471
1471
+
format!(" {}", hex)
1472
1472
+
} else {
1473
1473
+
hex
1474
1474
+
}
1475
1475
+
})
1476
1476
+
.collect()
1477
1477
+
}
1478
1478
+
1479
1479
+
/// CidLink field with validation
1480
1480
+
#[component]
1481
1481
+
fn EditableCidLinkField(
1482
1482
+
root: Signal<Data<'static>>,
1483
1483
+
path: String,
1484
1484
+
#[props(default)] remove_button: Option<Element>,
1485
1485
+
) -> Element {
1486
1486
+
let path_for_memo = path.clone();
1487
1487
+
let current_cid = use_memo(move || {
1488
1488
+
root.read()
1489
1489
+
.get_at_path(&path_for_memo)
1490
1490
+
.map(|d| match d {
1491
1491
+
Data::CidLink(cid) => cid.to_string(),
1492
1492
+
_ => String::new(),
1493
1493
+
})
1494
1494
+
.unwrap_or_default()
1495
1495
+
});
1496
1496
+
1497
1497
+
let mut input_text = use_signal(|| String::new());
1498
1498
+
let mut parse_error = use_signal(|| None::<String>);
1499
1499
+
1500
1500
+
use_effect(move || {
1501
1501
+
input_text.set(current_cid());
1502
1502
+
});
1503
1503
+
1504
1504
+
let input_width = use_memo(move || {
1505
1505
+
let len = input_text().len();
1506
1506
+
format!("{}ch", len.max(60))
1507
1507
+
});
1508
1508
+
1509
1509
+
let path_for_mutation = path.clone();
1510
1510
+
let handle_input = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| {
1511
1511
+
let text = evt.value();
1512
1512
+
input_text.set(text.clone());
1513
1513
+
1514
1514
+
match jacquard::types::cid::Cid::new_owned(text.as_bytes()) {
1515
1515
+
Ok(new_cid) => {
1516
1516
+
parse_error.set(None);
1517
1517
+
root.with_mut(|data| {
1518
1518
+
if let Some(target) = data.get_at_path_mut(&path_for_mutation) {
1519
1519
+
*target = Data::CidLink(new_cid);
1520
1520
+
}
1521
1521
+
});
1522
1522
+
}
1523
1523
+
Err(_) => {
1524
1524
+
parse_error.set(Some("Invalid CID format".to_string()));
1525
1525
+
}
1526
1526
+
}
1527
1527
+
};
1528
1528
+
1529
1529
+
rsx! {
1530
1530
+
div { class: "record-field cidlink-field",
1531
1531
+
div { class: "field-header",
1532
1532
+
PathLabel { path: path.clone() }
1533
1533
+
span { class: "string-type-tag", " [cid-link]" }
1534
1534
+
{remove_button}
1535
1535
+
}
1536
1536
+
input {
1537
1537
+
r#type: "text",
1538
1538
+
value: "{input_text}",
1539
1539
+
style: "width: {input_width}",
1540
1540
+
placeholder: "bafyrei...",
1541
1541
+
oninput: handle_input,
1542
1542
+
class: if parse_error().is_some() { "invalid" } else { "" },
1543
1543
+
}
1544
1544
+
if let Some(err) = parse_error() {
1545
1545
+
span { class: "field-error", " ❌ {err}" }
1546
1546
+
}
1547
1547
+
}
1548
1548
+
}
1549
1549
+
}
1550
1550
+
1313
1551
// ============================================================================
1314
1552
// Field with Remove Button Wrapper
1315
1553
// ============================================================================
···
1417
1655
}
1418
1656
}
1419
1657
div {
1420
1420
-
class: "add-field-widget",
1421
1421
-
button {
1658
1658
+
class: "array-item",
1659
1659
+
div {
1660
1660
+
class: "add-field-widget",
1661
1661
+
button {
1422
1662
1423
1423
-
onclick: move |_| {
1424
1424
-
root.with_mut(|data| {
1425
1425
-
if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_add) {
1426
1426
-
let new_item = create_array_item_default(arr);
1427
1427
-
arr.0.push(new_item);
1428
1428
-
}
1429
1429
-
});
1430
1430
-
},
1431
1431
-
"+ Add Item"
1432
1432
-
}
1663
1663
+
onclick: move |_| {
1664
1664
+
root.with_mut(|data| {
1665
1665
+
if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_add) {
1666
1666
+
let new_item = create_array_item_default(arr);
1667
1667
+
arr.0.push(new_item);
1668
1668
+
}
1669
1669
+
});
1670
1670
+
},
1671
1671
+
"+ Add Item"
1672
1672
+
}
1673
1673
+
}
1433
1674
}
1434
1675
1435
1676