tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
good progress on pretty editor
Orual
2 months ago
735d9d74
0b325539
+462
-17
2 changed files
expand all
collapse all
unified
split
crates
weaver-app
assets
styling
record-view.css
src
views
record.rs
+132
-5
crates/weaver-app/assets/styling/record-view.css
···
169
169
display: flex;
170
170
flex-direction: column;
171
171
padding: 0rem 0 0rem 1rem;
172
172
-
173
172
padding-right: 1rem;
174
173
border-left: 2px solid var(--color-secondary);
175
174
border-bottom: 1px dashed var(--color-subtle);
···
241
240
padding-bottom: 0.25rem;
242
241
border-left: 2px solid var(--color-primary);
243
242
border-bottom: 1px dashed var(--color-muted);
244
244
-
margin-left: -1.51rem;
243
243
+
margin-left: -1.55rem;
245
244
}
246
245
247
246
.section-content .section-label {
···
251
250
font-weight: 600;
252
251
padding-left: 1rem;
253
252
padding-top: 0.5rem;
254
254
-
margin-left: -0.01rem;
253
253
+
margin-left: -0.02rem;
255
254
border-left: 2px solid var(--color-secondary);
256
255
}
257
256
···
265
264
.section-content .record-field {
266
265
border-left-color: var(--color-secondary);
267
266
opacity: 0.95;
267
267
+
align-self: stretch;
268
268
+
width: 100%;
268
269
}
269
270
270
271
.blob-image {
···
462
463
margin: 0.25rem 0;
463
464
}
464
465
466
466
+
/* Array Section Styling */
467
467
+
.section-header {
468
468
+
display: flex;
469
469
+
align-items: baseline;
470
470
+
gap: 0.5rem;
471
471
+
}
472
472
+
473
473
+
.array-item .section-header {
474
474
+
margin-left: -1.52rem;
475
475
+
}
476
476
+
477
477
+
.array-length {
478
478
+
font-family: var(--font-mono);
479
479
+
font-size: 0.8rem;
480
480
+
color: var(--color-subtle);
481
481
+
font-weight: normal;
482
482
+
}
483
483
+
484
484
+
.array-item {
485
485
+
width: 100%;
486
486
+
display: flex;
487
487
+
flex-direction: column;
488
488
+
}
489
489
+
465
490
/* Pretty Editor Input Fields */
466
491
.record-field input[type="text"],
467
492
.record-field input[type="number"] {
···
474
499
padding: 0.2rem 0 0.1rem 0;
475
500
margin-top: 0.1rem;
476
501
outline: none;
502
502
+
min-width: 10ch;
503
503
+
max-width: 100%;
504
504
+
width: auto;
505
505
+
flex: 1 1 auto;
506
506
+
transition: border-color 0.2s;
507
507
+
}
508
508
+
509
509
+
/* Hide number input spinner arrows */
510
510
+
.record-field input[type="number"] {
511
511
+
-moz-appearance: textfield;
512
512
+
}
513
513
+
514
514
+
.record-field input[type="number"]::-webkit-outer-spin-button,
515
515
+
.record-field input[type="number"]::-webkit-inner-spin-button {
516
516
+
-webkit-appearance: none;
517
517
+
margin: 0;
518
518
+
}
519
519
+
520
520
+
.record-field textarea {
521
521
+
font-family: var(--font-mono);
522
522
+
font-size: 0.9rem;
523
523
+
color: var(--color-text);
524
524
+
background: transparent;
525
525
+
border: none;
526
526
+
border-bottom: 1px solid transparent;
527
527
+
padding: 0.2rem 0 0.1rem 0;
528
528
+
margin-top: 0.1rem;
529
529
+
outline: none;
477
530
width: 100%;
531
531
+
min-height: 1.5em;
478
532
transition: border-color 0.2s;
533
533
+
overflow-y: hidden;
534
534
+
}
535
535
+
536
536
+
/* Style textarea resize handle - webkit (Chrome/Safari) */
537
537
+
.record-field textarea::-webkit-resizer {
538
538
+
background: transparent;
539
539
+
border: 2px solid var(--color-border);
540
540
+
border-left: none;
541
541
+
border-bottom: none;
542
542
+
border-top: none;
543
543
+
}
544
544
+
545
545
+
/* Style textarea resize handle - Firefox */
546
546
+
.record-field textarea {
547
547
+
scrollbar-color: var(--color-border) transparent;
548
548
+
scrollbar-width: thin;
479
549
}
480
550
481
551
.record-field input[type="text"]:focus,
482
482
-
.record-field input[type="number"]:focus {
552
552
+
.record-field input[type="number"]:focus,
553
553
+
.record-field textarea:focus {
483
554
border-bottom-color: var(--color-primary);
484
555
}
485
556
486
557
.record-field input[type="text"].invalid,
487
487
-
.record-field input[type="number"].invalid {
558
558
+
.record-field input[type="number"].invalid,
559
559
+
.record-field textarea.invalid {
488
560
border-bottom-color: var(--color-error, #ff6b6b);
489
561
}
490
562
···
592
664
.field-remove-button:hover {
593
665
color: var(--color-error, #ff6b6b);
594
666
}
667
667
+
668
668
+
/* Blob Field Styling */
669
669
+
.blob-field {
670
670
+
/* Inherits from .record-field */
671
671
+
}
672
672
+
673
673
+
.blob-fields {
674
674
+
display: flex;
675
675
+
flex-direction: column;
676
676
+
gap: 0.5rem;
677
677
+
padding-top: 0.5rem;
678
678
+
width: 100%;
679
679
+
}
680
680
+
681
681
+
.blob-field-row {
682
682
+
display: flex;
683
683
+
align-items: baseline;
684
684
+
gap: 0.5rem;
685
685
+
width: 100%;
686
686
+
}
687
687
+
688
688
+
.blob-field-row label {
689
689
+
font-family: var(--font-mono);
690
690
+
font-size: 0.75rem;
691
691
+
color: var(--color-subtle);
692
692
+
min-width: 4rem;
693
693
+
text-transform: uppercase;
694
694
+
letter-spacing: 0.05em;
695
695
+
flex-shrink: 0;
696
696
+
}
697
697
+
698
698
+
.blob-field-cid {
699
699
+
flex-wrap: wrap;
700
700
+
}
701
701
+
702
702
+
.blob-field-cid input {
703
703
+
flex: 1 1 auto;
704
704
+
width: 100% !important;
705
705
+
min-width: unset !important;
706
706
+
max-width: unset !important;
707
707
+
font-size: 0.75rem;
708
708
+
}
709
709
+
710
710
+
.blob-field-row .readonly {
711
711
+
font-family: var(--font-mono);
712
712
+
font-size: 0.85rem;
713
713
+
color: var(--color-text);
714
714
+
opacity: 0.7;
715
715
+
}
716
716
+
717
717
+
.blob-upload-note {
718
718
+
font-size: 0.75rem;
719
719
+
font-style: italic;
720
720
+
padding-top: 0.25rem;
721
721
+
}
+330
-12
crates/weaver-app/src/views/record.rs
···
813
813
}
814
814
}
815
815
816
816
+
/// Create default value for new array item by cloning structure of existing items
817
817
+
fn create_array_item_default(arr: &jacquard::types::value::Array) -> Data<'static> {
818
818
+
if let Some(existing) = arr.0.first() {
819
819
+
clone_structure(existing)
820
820
+
} else {
821
821
+
// Empty array, default to null (user can change type)
822
822
+
Data::Null
823
823
+
}
824
824
+
}
825
825
+
826
826
+
/// Clone structure of Data, setting sensible defaults for leaf values
827
827
+
fn clone_structure(data: &Data) -> Data<'static> {
828
828
+
use jacquard::types::string::*;
829
829
+
use jacquard::types::value::{Array, Object};
830
830
+
use jacquard::types::{LexiconStringType, blob::*};
831
831
+
use std::collections::BTreeMap;
832
832
+
833
833
+
match data {
834
834
+
Data::Object(obj) => {
835
835
+
let mut new_obj = BTreeMap::new();
836
836
+
for (key, value) in obj.0.iter() {
837
837
+
new_obj.insert(key.clone(), clone_structure(value));
838
838
+
}
839
839
+
Data::Object(Object(new_obj))
840
840
+
}
841
841
+
842
842
+
Data::Array(_) => Data::Array(Array(Vec::new())),
843
843
+
844
844
+
Data::String(s) => match s.string_type() {
845
845
+
LexiconStringType::Datetime => {
846
846
+
// Sensible default: now
847
847
+
Data::String(AtprotoStr::Datetime(Datetime::now()))
848
848
+
}
849
849
+
_ => {
850
850
+
// Empty string, type inference will handle it
851
851
+
Data::String(AtprotoStr::String("".into()))
852
852
+
}
853
853
+
},
854
854
+
855
855
+
Data::Integer(_) => Data::Integer(0),
856
856
+
Data::Boolean(_) => Data::Boolean(false),
857
857
+
858
858
+
Data::Blob(blob) => {
859
859
+
// Placeholder blob
860
860
+
Data::Blob(
861
861
+
Blob {
862
862
+
r#ref: CidLink::str(
863
863
+
"bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
864
864
+
),
865
865
+
mime_type: blob.mime_type.clone(),
866
866
+
size: 0,
867
867
+
}
868
868
+
.into_static(),
869
869
+
)
870
870
+
}
871
871
+
872
872
+
Data::Bytes(_) | Data::CidLink(_) | Data::Null => Data::Null,
873
873
+
}
874
874
+
}
875
875
+
816
876
// ============================================================================
817
877
// Pretty Editor: Component Hierarchy
818
878
// ============================================================================
···
832
892
.get_at_path(&path_for_memo)
833
893
.map(|d| d.clone().into_static())
834
894
{
835
835
-
Some(Data::Object(_)) => rsx! { EditableObjectField { root, path: path.clone(), did } },
895
895
+
Some(Data::Object(_)) => rsx! { EditableObjectField { root, path: path.clone(), did, remove_button } },
896
896
+
Some(Data::Array(_)) => rsx! { EditableArrayField { root, path: path.clone(), did } },
836
897
Some(Data::String(_)) => {
837
898
rsx! { EditableStringField { root, path: path.clone(), remove_button } }
838
899
}
···
843
904
rsx! { EditableBooleanField { root, path: path.clone(), remove_button } }
844
905
}
845
906
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 } },
846
908
847
909
// Not yet implemented - fall back to read-only DataView
848
910
Some(data) => {
···
917
979
};
918
980
919
981
let type_label = format!("{:?}", string_type()).to_lowercase();
982
982
+
let is_plain_string = string_type() == LexiconStringType::String;
920
983
921
984
rsx! {
922
985
div { class: "record-field",
···
927
990
}
928
991
{remove_button}
929
992
}
930
930
-
input {
931
931
-
r#type: "text",
932
932
-
value: "{input_text}",
933
933
-
oninput: handle_input,
934
934
-
class: if parse_error().is_some() { "invalid" } else { "" },
993
993
+
if is_plain_string {
994
994
+
textarea {
995
995
+
value: "{input_text}",
996
996
+
oninput: handle_input,
997
997
+
class: if parse_error().is_some() { "invalid" } else { "" },
998
998
+
rows: "1",
999
999
+
}
1000
1000
+
} else {
1001
1001
+
input {
1002
1002
+
r#type: "text",
1003
1003
+
value: "{input_text}",
1004
1004
+
oninput: handle_input,
1005
1005
+
class: if parse_error().is_some() { "invalid" } else { "" },
1006
1006
+
}
935
1007
}
936
1008
if let Some(err) = parse_error() {
937
1009
span { class: "field-error", " ❌ {err}" }
···
1086
1158
}
1087
1159
}
1088
1160
1161
1161
+
/// Blob field - shows CID, size (editable), mime type (read-only)
1162
1162
+
#[component]
1163
1163
+
fn EditableBlobField(
1164
1164
+
root: Signal<Data<'static>>,
1165
1165
+
path: String,
1166
1166
+
did: String,
1167
1167
+
#[props(default)] remove_button: Option<Element>,
1168
1168
+
) -> Element {
1169
1169
+
let path_for_memo = path.clone();
1170
1170
+
let blob_data = use_memo(move || {
1171
1171
+
root.read()
1172
1172
+
.get_at_path(&path_for_memo)
1173
1173
+
.and_then(|d| match d {
1174
1174
+
Data::Blob(blob) => Some((
1175
1175
+
blob.r#ref.to_string(),
1176
1176
+
blob.size,
1177
1177
+
blob.mime_type.as_str().to_string(),
1178
1178
+
)),
1179
1179
+
_ => None,
1180
1180
+
})
1181
1181
+
});
1182
1182
+
1183
1183
+
let mut cid_input = use_signal(|| String::new());
1184
1184
+
let mut size_input = use_signal(|| String::new());
1185
1185
+
let mut cid_error = use_signal(|| None::<String>);
1186
1186
+
let mut size_error = use_signal(|| None::<String>);
1187
1187
+
1188
1188
+
// Sync inputs when blob data changes
1189
1189
+
use_effect(move || {
1190
1190
+
if let Some((cid, size, _)) = blob_data() {
1191
1191
+
cid_input.set(cid);
1192
1192
+
size_input.set(size.to_string());
1193
1193
+
}
1194
1194
+
});
1195
1195
+
1196
1196
+
let path_for_cid = path.clone();
1197
1197
+
let handle_cid_change = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| {
1198
1198
+
let text = evt.value();
1199
1199
+
cid_input.set(text.clone());
1200
1200
+
1201
1201
+
match jacquard::types::cid::CidLink::new_owned(text.as_bytes()) {
1202
1202
+
Ok(new_cid_link) => {
1203
1203
+
cid_error.set(None);
1204
1204
+
root.with_mut(|data| {
1205
1205
+
if let Some(Data::Blob(blob)) = data.get_at_path_mut(&path_for_cid) {
1206
1206
+
blob.r#ref = new_cid_link;
1207
1207
+
}
1208
1208
+
});
1209
1209
+
}
1210
1210
+
Err(_) => {
1211
1211
+
cid_error.set(Some("Invalid CID format".to_string()));
1212
1212
+
}
1213
1213
+
}
1214
1214
+
};
1215
1215
+
1216
1216
+
let path_for_size = path.clone();
1217
1217
+
let handle_size_change = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| {
1218
1218
+
let text = evt.value();
1219
1219
+
size_input.set(text.clone());
1220
1220
+
1221
1221
+
match text.parse::<usize>() {
1222
1222
+
Ok(new_size) => {
1223
1223
+
size_error.set(None);
1224
1224
+
root.with_mut(|data| {
1225
1225
+
if let Some(Data::Blob(blob)) = data.get_at_path_mut(&path_for_size) {
1226
1226
+
blob.size = new_size;
1227
1227
+
}
1228
1228
+
});
1229
1229
+
}
1230
1230
+
Err(_) => {
1231
1231
+
size_error.set(Some("Must be a non-negative integer".to_string()));
1232
1232
+
}
1233
1233
+
}
1234
1234
+
};
1235
1235
+
1236
1236
+
let placeholder_cid = "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1237
1237
+
let is_placeholder = blob_data()
1238
1238
+
.map(|(cid, _, _)| cid == placeholder_cid)
1239
1239
+
.unwrap_or(true);
1240
1240
+
let is_image = blob_data()
1241
1241
+
.map(|(_, _, mime)| mime.starts_with("image/"))
1242
1242
+
.unwrap_or(false);
1243
1243
+
1244
1244
+
let image_url = if !is_placeholder && is_image {
1245
1245
+
blob_data().map(|(cid, _, mime)| {
1246
1246
+
let format = mime.strip_prefix("image/").unwrap_or("jpeg");
1247
1247
+
format!(
1248
1248
+
"https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}",
1249
1249
+
did,
1250
1250
+
cid,
1251
1251
+
format
1252
1252
+
)
1253
1253
+
})
1254
1254
+
} else {
1255
1255
+
None
1256
1256
+
};
1257
1257
+
1258
1258
+
rsx! {
1259
1259
+
div { class: "record-field blob-field",
1260
1260
+
div { class: "field-header",
1261
1261
+
PathLabel { path: path.clone() }
1262
1262
+
span { class: "string-type-tag", " [blob]" }
1263
1263
+
{remove_button}
1264
1264
+
}
1265
1265
+
div { class: "blob-fields",
1266
1266
+
div { class: "blob-field-row blob-field-cid",
1267
1267
+
label { "CID:" }
1268
1268
+
input {
1269
1269
+
r#type: "text",
1270
1270
+
value: "{cid_input}",
1271
1271
+
oninput: handle_cid_change,
1272
1272
+
class: if cid_error().is_some() { "invalid" } else { "" },
1273
1273
+
}
1274
1274
+
if let Some(err) = cid_error() {
1275
1275
+
span { class: "field-error", " ❌ {err}" }
1276
1276
+
}
1277
1277
+
}
1278
1278
+
div { class: "blob-field-row",
1279
1279
+
label { "Size:" }
1280
1280
+
input {
1281
1281
+
r#type: "number",
1282
1282
+
value: "{size_input}",
1283
1283
+
oninput: handle_size_change,
1284
1284
+
class: if size_error().is_some() { "invalid" } else { "" },
1285
1285
+
}
1286
1286
+
if let Some(err) = size_error() {
1287
1287
+
span { class: "field-error", " ❌ {err}" }
1288
1288
+
}
1289
1289
+
}
1290
1290
+
div { class: "blob-field-row",
1291
1291
+
label { "MIME Type:" }
1292
1292
+
span { class: "readonly",
1293
1293
+
"{blob_data().map(|(_, _, mime)| mime).unwrap_or_default()}"
1294
1294
+
}
1295
1295
+
}
1296
1296
+
if let Some(url) = image_url {
1297
1297
+
img {
1298
1298
+
src: "{url}",
1299
1299
+
alt: "Blob preview",
1300
1300
+
class: "blob-image",
1301
1301
+
}
1302
1302
+
}
1303
1303
+
if is_placeholder {
1304
1304
+
div { class: "muted blob-upload-note",
1305
1305
+
"File upload coming soon"
1306
1306
+
}
1307
1307
+
}
1308
1308
+
}
1309
1309
+
}
1310
1310
+
}
1311
1311
+
}
1312
1312
+
1089
1313
// ============================================================================
1090
1314
// Field with Remove Button Wrapper
1091
1315
// ============================================================================
···
1129
1353
}
1130
1354
1131
1355
// ============================================================================
1356
1356
+
// Array Field Editor (enables recursion)
1357
1357
+
// ============================================================================
1358
1358
+
1359
1359
+
/// Array field - iterates items and renders child EditableDataView for each
1360
1360
+
#[component]
1361
1361
+
fn EditableArrayField(root: Signal<Data<'static>>, path: String, did: String) -> Element {
1362
1362
+
let path_for_memo = path.clone();
1363
1363
+
let array_len = use_memo(move || {
1364
1364
+
root.read()
1365
1365
+
.get_at_path(&path_for_memo)
1366
1366
+
.and_then(|d| d.as_array())
1367
1367
+
.map(|arr| arr.0.len())
1368
1368
+
.unwrap_or(0)
1369
1369
+
});
1370
1370
+
1371
1371
+
let path_for_add = path.clone();
1372
1372
+
1373
1373
+
rsx! {
1374
1374
+
div { class: "record-section array-section",
1375
1375
+
div { class: "section-header",
1376
1376
+
div { class: "section-label",
1377
1377
+
{
1378
1378
+
let parts: Vec<&str> = path.split('.').collect();
1379
1379
+
let final_part = parts.last().unwrap_or(&"");
1380
1380
+
rsx! { "{final_part}" }
1381
1381
+
}
1382
1382
+
}
1383
1383
+
span { class: "array-length", "[{array_len}]" }
1384
1384
+
}
1385
1385
+
1386
1386
+
div { class: "section-content",
1387
1387
+
for idx in 0..array_len() {
1388
1388
+
{
1389
1389
+
let item_path = format!("{}[{}]", path, idx);
1390
1390
+
let path_for_remove = path.clone();
1391
1391
+
1392
1392
+
rsx! {
1393
1393
+
div {
1394
1394
+
class: "array-item",
1395
1395
+
key: "{item_path}",
1396
1396
+
1397
1397
+
EditableDataView {
1398
1398
+
root: root,
1399
1399
+
path: item_path.clone(),
1400
1400
+
did: did.clone(),
1401
1401
+
remove_button: rsx! {
1402
1402
+
button {
1403
1403
+
class: "field-remove-button",
1404
1404
+
onclick: move |_| {
1405
1405
+
root.with_mut(|data| {
1406
1406
+
if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_remove) {
1407
1407
+
arr.0.remove(idx);
1408
1408
+
}
1409
1409
+
});
1410
1410
+
},
1411
1411
+
"Remove"
1412
1412
+
}
1413
1413
+
}
1414
1414
+
}
1415
1415
+
}
1416
1416
+
}
1417
1417
+
}
1418
1418
+
}
1419
1419
+
div {
1420
1420
+
class: "add-field-widget",
1421
1421
+
button {
1422
1422
+
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
+
}
1433
1433
+
}
1434
1434
+
1435
1435
+
1436
1436
+
}
1437
1437
+
}
1438
1438
+
}
1439
1439
+
}
1440
1440
+
1441
1441
+
// ============================================================================
1132
1442
// Object Field Editor (enables recursion)
1133
1443
// ============================================================================
1134
1444
1135
1445
/// Object field - iterates fields and renders child EditableDataView for each
1136
1446
#[component]
1137
1137
-
fn EditableObjectField(root: Signal<Data<'static>>, path: String, did: String) -> Element {
1447
1447
+
fn EditableObjectField(
1448
1448
+
root: Signal<Data<'static>>,
1449
1449
+
path: String,
1450
1450
+
did: String,
1451
1451
+
#[props(default)] remove_button: Option<Element>,
1452
1452
+
) -> Element {
1138
1453
let path_for_memo = path.clone();
1139
1454
let field_keys = use_memo(move || {
1140
1455
root.read()
···
1149
1464
rsx! {
1150
1465
if !is_root {
1151
1466
div { class: "record-section object-section",
1152
1152
-
div { class: "section-label",
1153
1153
-
{
1154
1154
-
let parts: Vec<&str> = path.split('.').collect();
1155
1155
-
let final_part = parts.last().unwrap_or(&"");
1156
1156
-
rsx! { "{final_part}" }
1467
1467
+
div { class: "section-header",
1468
1468
+
div { class: "section-label",
1469
1469
+
{
1470
1470
+
let parts: Vec<&str> = path.split('.').collect();
1471
1471
+
let final_part = parts.last().unwrap_or(&"");
1472
1472
+
rsx! { "{final_part}" }
1473
1473
+
}
1157
1474
}
1475
1475
+
{remove_button}
1158
1476
}
1159
1477
div { class: "section-content",
1160
1478
for key in field_keys() {