tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
collapsible objects & arrays, improved lexicon resolution
Orual
2 months ago
1f3e4380
da465d03
+259
-207
4 changed files
expand all
collapse all
unified
split
crates
weaver-app
assets
styling
record-view.css
src
components
accordion
component.rs
style.css
views
record.rs
+41
-1
crates/weaver-app/assets/styling/record-view.css
···
7
7
.record-header {
8
8
margin-bottom: 2rem;
9
9
padding-bottom: 0.5rem;
10
10
-
11
10
font-family: var(--font-mono);
12
11
border-bottom: 1px solid var(--color-border);
13
12
}
···
931
930
color: var(--color-error, #ff5252);
932
931
border-bottom-color: var(--color-error, #ff6b6b);
933
932
}
933
933
+
934
934
+
.accordion-content {
935
935
+
grid-template-rows: 1fr;
936
936
+
padding-left: 2.8rem;
937
937
+
}
938
938
+
939
939
+
.accordion-content .section-content {
940
940
+
margin-left: -1px;
941
941
+
}
942
942
+
943
943
+
.accordion-content .record-field {
944
944
+
margin-left: 0px;
945
945
+
}
946
946
+
947
947
+
.accordion-content .array-item {
948
948
+
margin-left: 0px;
949
949
+
}
950
950
+
951
951
+
.accordion-content .array-item .section-content {
952
952
+
border-collapse: collapse;
953
953
+
}
954
954
+
955
955
+
.accordion-content .array-item .record-section {
956
956
+
margin-left: 1.5rem;
957
957
+
}
958
958
+
959
959
+
.accordion-content .array-item .record-section .record-field {
960
960
+
margin-left: -1px;
961
961
+
}
962
962
+
963
963
+
.accordion-content .array-item .record-section .accordion-trigger .section-label {
964
964
+
margin-left: -2px;
965
965
+
}
966
966
+
967
967
+
.accordion-trigger {
968
968
+
background-color: var(--color-base) !important;
969
969
+
}
970
970
+
971
971
+
.accordion {
972
972
+
margin-left: -2.8rem;
973
973
+
}
+1
-3
crates/weaver-app/src/components/accordion/component.rs
···
9
9
document::Link { rel: "stylesheet", href: asset!("./style.css") }
10
10
accordion::Accordion {
11
11
class: "accordion",
12
12
-
width: "15rem",
13
12
id: props.id,
14
13
allow_multiple_open: props.allow_multiple_open,
15
14
disabled: props.disabled,
···
44
43
class: "accordion-trigger",
45
44
id: props.id,
46
45
attributes: props.attributes,
47
47
-
{props.children}
48
46
svg {
49
47
class: "accordion-expand-icon",
50
48
view_box: "0 0 24 24",
51
49
xmlns: "http://www.w3.org/2000/svg",
52
50
polyline { points: "6 9 12 15 18 9" }
53
51
}
52
52
+
{props.children}
54
53
}
55
54
}
56
55
}
···
60
59
rsx! {
61
60
accordion::AccordionContent {
62
61
class: "accordion-content",
63
63
-
style: "--collapsible-content-width: 140px",
64
62
id: props.id,
65
63
attributes: props.attributes,
66
64
{props.children}
+31
-67
crates/weaver-app/src/components/accordion/style.css
···
1
1
.accordion-trigger {
2
2
-
display: flex;
3
3
-
width: 100%;
4
4
-
box-sizing: border-box;
5
5
-
flex-direction: row;
6
6
-
align-items: center;
7
7
-
justify-content: space-between;
8
8
-
padding: 0;
9
9
-
padding-top: 1rem;
10
10
-
padding-bottom: 1rem;
11
11
-
border: none;
12
12
-
background-color: transparent;
13
13
-
color: var(--secondary-color-4);
14
14
-
outline: none;
15
15
-
text-align: left;
16
16
-
}
17
17
-
18
18
-
.accordion-trigger:focus-visible {
19
19
-
border: none;
20
20
-
box-shadow: inset 0 0 0 2px var(--focused-border-color);
2
2
+
display: inline-flex;
3
3
+
box-sizing: border-box;
4
4
+
flex-direction: row;
5
5
+
align-items: center;
6
6
+
padding: 0;
7
7
+
border: none;
8
8
+
background-color: transparent;
9
9
+
outline: none;
10
10
+
text-align: left;
21
11
}
22
12
23
13
.accordion-trigger:hover {
24
24
-
cursor: pointer;
25
25
-
text-decoration-line: underline;
14
14
+
cursor: pointer;
26
15
}
27
16
28
17
.accordion-content {
29
29
-
display: grid;
30
30
-
height: 0;
31
31
-
}
32
32
-
33
33
-
.accordion-content[data-open="false"] {
34
34
-
animation: accordion-slide-down 300ms cubic-bezier(0.87, 0, 0.13, 1) forwards;
18
18
+
display: grid;
19
19
+
overflow: hidden;
20
20
+
background-color: transparent;
21
21
+
transition: grid-template-rows 300ms cubic-bezier(0.87, 0, 0.13, 1);
22
22
+
grid-template-rows: 0fr;
35
23
}
36
24
37
25
.accordion-content[data-open="true"] {
38
38
-
animation: accordion-slide-up 300ms cubic-bezier(0.87, 0, 0.13, 1) forwards;
39
39
-
}
40
40
-
41
41
-
@keyframes accordion-slide-down {
42
42
-
from {
43
43
-
height: var(--collapsible-content-width);
44
44
-
}
45
45
-
46
46
-
to {
47
47
-
height: 0;
48
48
-
}
26
26
+
grid-template-rows: 1fr;
49
27
}
50
28
51
51
-
@keyframes accordion-slide-up {
52
52
-
from {
53
53
-
height: 0;
54
54
-
}
55
55
-
56
56
-
to {
57
57
-
height: var(--collapsible-content-width);
58
58
-
}
29
29
+
.accordion-content > * {
30
30
+
overflow: hidden;
59
31
}
60
32
61
33
.accordion-item {
62
62
-
overflow: hidden;
63
63
-
box-sizing: border-box;
64
64
-
border-bottom: 1px solid var(--primary-color-6);
65
65
-
margin-top: 1px;
66
66
-
}
67
67
-
68
68
-
.accordion-item:first-child {
69
69
-
margin-top: 0;
70
70
-
}
71
71
-
72
72
-
.accordion-item:last-child {
73
73
-
border-bottom: none;
34
34
+
box-sizing: border-box;
74
35
}
75
36
76
37
.accordion-expand-icon {
77
77
-
width: 20px;
78
78
-
height: 20px;
79
79
-
fill: none;
80
80
-
stroke: var(--secondary-color-4);
81
81
-
stroke-linecap: round;
82
82
-
stroke-linejoin: round;
83
83
-
stroke-width: 2;
84
84
-
transition: rotate 150ms cubic-bezier(0.4, 0, 0.2, 1);
38
38
+
width: 16px;
39
39
+
height: 16px;
40
40
+
flex-shrink: 0;
41
41
+
fill: none;
42
42
+
stroke: var(--color-subtle);
43
43
+
stroke-linecap: round;
44
44
+
stroke-linejoin: round;
45
45
+
stroke-width: 2;
46
46
+
transition: rotate 150ms cubic-bezier(0.4, 0, 0.2, 1);
47
47
+
margin-right: 0.35rem;
48
48
+
opacity: 0.7;
85
49
}
86
50
87
51
.accordion-item[data-open="true"] .accordion-expand-icon {
88
88
-
rotate: 180deg;
52
52
+
rotate: 180deg;
89
53
}
+186
-136
crates/weaver-app/src/views/record.rs
···
1
1
use crate::Route;
2
2
use crate::auth::AuthState;
3
3
+
use crate::components::accordion::{Accordion, AccordionContent, AccordionItem, AccordionTrigger};
3
4
use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle};
4
5
use crate::fetch::CachedFetcher;
5
6
use dioxus::{CapturedError, prelude::*};
···
130
131
131
132
// Find refs in the schema and resolve them
132
133
if let Ok(schema_data) = to_data(&schema.doc) {
133
133
-
for ref_val in schema_data.query("..ref").values() {
134
134
+
for ref_val in schema_data.query("...ref").values() {
135
135
+
if let Some(ref_str) = ref_val.as_str() {
136
136
+
if ref_str.contains('.') {
137
137
+
resolve_schema_with_refs(fetcher, ref_str, validator, resolved)
138
138
+
.await;
139
139
+
}
140
140
+
}
141
141
+
}
142
142
+
for ref_val in schema_data.query("...refs").values() {
134
143
if let Some(ref_str) = ref_val.as_str() {
135
144
if ref_str.contains('.') {
136
145
resolve_schema_with_refs(fetcher, ref_str, validator, resolved)
···
596
605
let label = path.split('.').last().unwrap_or(&path);
597
606
rsx! {
598
607
div { class: "record-section",
599
599
-
div { class: "section-label", "{label}" span { class: "array-len", "[{arr.len()}] " } }
600
600
-
if has_errors {
601
601
-
for error in &errors {
602
602
-
div { class: "field-error-message", "{error}" }
603
603
-
}
604
604
-
}
605
605
-
div { class: "section-content",
606
606
-
for (idx, item) in arr.iter().enumerate() {
607
607
-
{
608
608
-
let item_path = format!("{}[{}]", label, idx);
609
609
-
let is_object = matches!(item, Data::Object(_));
608
608
+
Accordion {
609
609
+
id: "array-{path}",
610
610
+
collapsible: true,
611
611
+
AccordionItem {
612
612
+
default_open: true,
613
613
+
index: 0,
614
614
+
AccordionTrigger {
615
615
+
div { class: "section-label", "{label}" span { class: "array-len", "[{arr.len()}]" } }
616
616
+
}
617
617
+
AccordionContent {
618
618
+
if has_errors {
619
619
+
for error in &errors {
620
620
+
div { class: "field-error-message", "{error}" }
621
621
+
}
622
622
+
}
623
623
+
div { class: "section-content",
624
624
+
for (idx, item) in arr.iter().enumerate() {
625
625
+
{
626
626
+
let item_path = format!("{}[{}]", label, idx);
627
627
+
let is_object = matches!(item, Data::Object(_));
610
628
611
611
-
if is_object {
612
612
-
rsx! {
613
613
-
614
614
-
div {
615
615
-
class: "array-item",
616
616
-
div { class: "record-section",
617
617
-
div { class: "section-label", "{item_path}" }
618
618
-
div { class: "section-content",
619
619
-
DataView {
620
620
-
data: item.clone(),
621
621
-
root_data,
622
622
-
path: item_path.clone(),
623
623
-
did: did.clone()
629
629
+
if is_object {
630
630
+
rsx! {
631
631
+
div {
632
632
+
class: "array-item",
633
633
+
div { class: "record-section",
634
634
+
div { class: "section-label", "{item_path}" }
635
635
+
div { class: "section-content",
636
636
+
DataView {
637
637
+
data: item.clone(),
638
638
+
root_data,
639
639
+
path: item_path.clone(),
640
640
+
did: did.clone()
641
641
+
}
642
642
+
}
643
643
+
}
644
644
+
}
645
645
+
}
646
646
+
} else {
647
647
+
rsx! {
648
648
+
div {
649
649
+
class: "array-item",
650
650
+
DataView {
651
651
+
data: item.clone(),
652
652
+
root_data,
653
653
+
path: item_path,
654
654
+
did: did.clone()
655
655
+
}
656
656
+
}
624
657
}
625
658
}
626
626
-
}
627
627
-
}
628
628
-
}
629
629
-
} else {
630
630
-
631
631
-
rsx! {
632
632
-
633
633
-
div {
634
634
-
class: "array-item",
635
635
-
DataView {
636
636
-
data: item.clone(),
637
637
-
root_data,
638
638
-
path: item_path,
639
639
-
did: did.clone()
640
640
-
}
641
659
}
642
660
}
643
661
}
···
679
697
// Nested object (not array item): wrap in section
680
698
let label = path.split('.').last().unwrap_or(&path);
681
699
rsx! {
682
682
-
683
683
-
div { class: "section-label", "{label}" }
684
684
-
if has_errors {
685
685
-
for error in &errors {
686
686
-
div { class: "field-error-message", "{error}" }
687
687
-
}
688
688
-
}
689
700
div { class: "record-section",
690
690
-
div { class: "section-content",
691
691
-
for (key, value) in obj.iter() {
692
692
-
{
693
693
-
let new_path = format!("{}.{}", path, key);
694
694
-
let did_clone = did.clone();
695
695
-
rsx! {
696
696
-
DataView { data: value.clone(), root_data, path: new_path, did: did_clone }
701
701
+
Accordion {
702
702
+
id: "object-{path}",
703
703
+
collapsible: true,
704
704
+
AccordionItem {
705
705
+
default_open: true,
706
706
+
index: 0,
707
707
+
AccordionTrigger {
708
708
+
div { class: "section-label", "{label}" }
709
709
+
}
710
710
+
AccordionContent {
711
711
+
if has_errors {
712
712
+
for error in &errors {
713
713
+
div { class: "field-error-message", "{error}" }
714
714
+
}
715
715
+
}
716
716
+
div { class: "section-content",
717
717
+
for (key, value) in obj.iter() {
718
718
+
{
719
719
+
let new_path = format!("{}.{}", path, key);
720
720
+
let did_clone = did.clone();
721
721
+
rsx! {
722
722
+
DataView { data: value.clone(), root_data, path: new_path, did: did_clone }
723
723
+
}
724
724
+
}
725
725
+
}
697
726
}
698
727
}
699
728
}
···
1815
1844
1816
1845
match text.parse::<usize>() {
1817
1846
Ok(new_size) => {
1847
1847
+
size_input.set(format_size(new_size, humansize::BINARY));
1818
1848
size_error.set(None);
1819
1849
root.with_mut(|data| {
1820
1850
if let Some(Data::Blob(blob)) = data.get_at_path_mut(&path_for_size) {
···
2275
2305
2276
2306
rsx! {
2277
2307
div { class: "record-section array-section",
2278
2278
-
div { class: "section-header",
2279
2279
-
div { class: "section-label",
2280
2280
-
{
2281
2281
-
let parts: Vec<&str> = path.split('.').collect();
2282
2282
-
let final_part = parts.last().unwrap_or(&"");
2283
2283
-
rsx! { "{final_part}" }
2308
2308
+
Accordion {
2309
2309
+
id: "edit-array-{path}",
2310
2310
+
collapsible: true,
2311
2311
+
AccordionItem {
2312
2312
+
default_open: true,
2313
2313
+
index: 0,
2314
2314
+
AccordionTrigger {
2315
2315
+
div { class: "section-header",
2316
2316
+
div { class: "section-label",
2317
2317
+
{
2318
2318
+
let parts: Vec<&str> = path.split('.').collect();
2319
2319
+
let final_part = parts.last().unwrap_or(&"");
2320
2320
+
rsx! { "{final_part}" }
2321
2321
+
}
2322
2322
+
}
2323
2323
+
span { class: "array-length", "[{array_len}]" }
2324
2324
+
}
2284
2325
}
2285
2285
-
}
2286
2286
-
span { class: "array-length", "[{array_len}]" }
2287
2287
-
}
2326
2326
+
AccordionContent {
2327
2327
+
div { class: "section-content",
2328
2328
+
for idx in 0..array_len() {
2329
2329
+
{
2330
2330
+
let item_path = format!("{}[{}]", path, idx);
2331
2331
+
let path_for_remove = path.clone();
2288
2332
2289
2289
-
div { class: "section-content",
2290
2290
-
for idx in 0..array_len() {
2291
2291
-
{
2292
2292
-
let item_path = format!("{}[{}]", path, idx);
2293
2293
-
let path_for_remove = path.clone();
2333
2333
+
rsx! {
2334
2334
+
div {
2335
2335
+
class: "array-item",
2336
2336
+
key: "{item_path}",
2294
2337
2295
2295
-
rsx! {
2296
2296
-
div {
2297
2297
-
class: "array-item",
2298
2298
-
key: "{item_path}",
2299
2299
-
2300
2300
-
EditableDataView {
2301
2301
-
root: root,
2302
2302
-
path: item_path.clone(),
2303
2303
-
did: did.clone(),
2304
2304
-
remove_button: rsx! {
2305
2305
-
button {
2306
2306
-
class: "field-remove-button",
2307
2307
-
onclick: move |_| {
2308
2308
-
root.with_mut(|data| {
2309
2309
-
if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_remove) {
2310
2310
-
arr.0.remove(idx);
2338
2338
+
EditableDataView {
2339
2339
+
root: root,
2340
2340
+
path: item_path.clone(),
2341
2341
+
did: did.clone(),
2342
2342
+
remove_button: rsx! {
2343
2343
+
button {
2344
2344
+
class: "field-remove-button",
2345
2345
+
onclick: move |_| {
2346
2346
+
root.with_mut(|data| {
2347
2347
+
if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_remove) {
2348
2348
+
arr.0.remove(idx);
2349
2349
+
}
2350
2350
+
});
2351
2351
+
},
2352
2352
+
"Remove"
2311
2353
}
2312
2312
-
});
2313
2313
-
},
2314
2314
-
"Remove"
2354
2354
+
}
2355
2355
+
}
2315
2356
}
2316
2357
}
2317
2358
}
2318
2359
}
2319
2319
-
}
2320
2320
-
}
2321
2321
-
}
2322
2322
-
div {
2323
2323
-
class: "array-item",
2324
2324
-
div {
2325
2325
-
class: "add-field-widget",
2326
2326
-
button {
2327
2327
-
2328
2328
-
onclick: move |_| {
2329
2329
-
root.with_mut(|data| {
2330
2330
-
if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_add) {
2331
2331
-
let new_item = create_array_item_default(arr);
2332
2332
-
arr.0.push(new_item);
2360
2360
+
div {
2361
2361
+
class: "array-item",
2362
2362
+
div {
2363
2363
+
class: "add-field-widget",
2364
2364
+
button {
2365
2365
+
onclick: move |_| {
2366
2366
+
root.with_mut(|data| {
2367
2367
+
if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_add) {
2368
2368
+
let new_item = create_array_item_default(arr);
2369
2369
+
arr.0.push(new_item);
2370
2370
+
}
2371
2371
+
});
2372
2372
+
},
2373
2373
+
"+ Add Item"
2333
2374
}
2334
2334
-
});
2335
2335
-
},
2336
2336
-
"+ Add Item"
2375
2375
+
}
2376
2376
+
}
2337
2377
}
2338
2378
}
2339
2379
}
2340
2340
-
2341
2341
-
2342
2380
}
2343
2381
}
2344
2382
}
···
2370
2408
rsx! {
2371
2409
if !is_root {
2372
2410
div { class: "record-section object-section",
2373
2373
-
div { class: "section-header",
2374
2374
-
div { class: "section-label",
2375
2375
-
{
2376
2376
-
let parts: Vec<&str> = path.split('.').collect();
2377
2377
-
let final_part = parts.last().unwrap_or(&"");
2378
2378
-
rsx! { "{final_part}" }
2411
2411
+
Accordion {
2412
2412
+
id: "edit-object-{path}",
2413
2413
+
collapsible: true,
2414
2414
+
AccordionItem {
2415
2415
+
default_open: true,
2416
2416
+
index: 0,
2417
2417
+
AccordionTrigger {
2418
2418
+
div { class: "section-header",
2419
2419
+
div { class: "section-label",
2420
2420
+
{
2421
2421
+
let parts: Vec<&str> = path.split('.').collect();
2422
2422
+
let final_part = parts.last().unwrap_or(&"");
2423
2423
+
rsx! { "{final_part}" }
2424
2424
+
}
2425
2425
+
}
2426
2426
+
{remove_button}
2427
2427
+
}
2379
2428
}
2380
2380
-
}
2381
2381
-
{remove_button}
2382
2382
-
}
2383
2383
-
div { class: "section-content",
2384
2384
-
for key in field_keys() {
2385
2385
-
{
2386
2386
-
let field_path = if path.is_empty() {
2387
2387
-
key.to_string()
2388
2388
-
} else {
2389
2389
-
format!("{}.{}", path, key)
2390
2390
-
};
2391
2391
-
let is_type_field = key == "$type";
2429
2429
+
AccordionContent {
2430
2430
+
div { class: "section-content",
2431
2431
+
for key in field_keys() {
2432
2432
+
{
2433
2433
+
let field_path = if path.is_empty() {
2434
2434
+
key.to_string()
2435
2435
+
} else {
2436
2436
+
format!("{}.{}", path, key)
2437
2437
+
};
2438
2438
+
let is_type_field = key == "$type";
2392
2439
2393
2393
-
rsx! {
2394
2394
-
FieldWithRemove {
2395
2395
-
key: "{field_path}",
2396
2396
-
root: root,
2397
2397
-
path: field_path.clone(),
2398
2398
-
did: did.clone(),
2399
2399
-
is_removable: !is_type_field,
2400
2400
-
parent_path: path.clone(),
2401
2401
-
field_key: key.clone(),
2440
2440
+
rsx! {
2441
2441
+
FieldWithRemove {
2442
2442
+
key: "{field_path}",
2443
2443
+
root: root,
2444
2444
+
path: field_path.clone(),
2445
2445
+
did: did.clone(),
2446
2446
+
is_removable: !is_type_field,
2447
2447
+
parent_path: path.clone(),
2448
2448
+
field_key: key.clone(),
2449
2449
+
}
2450
2450
+
}
2451
2451
+
}
2452
2452
+
}
2453
2453
+
2454
2454
+
AddFieldWidget { root: root, path: path.clone() }
2402
2455
}
2403
2456
}
2404
2457
}
2405
2405
-
}
2406
2406
-
2407
2407
-
AddFieldWidget { root: root, path: path.clone() }
2408
2458
}
2409
2459
}
2410
2460
} else {