tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
WIP editor
Orual
2 months ago
3e94583b
e8840cba
+603
-21
6 changed files
expand all
collapse all
unified
split
Cargo.lock
crates
weaver-app
Cargo.toml
assets
styling
record-view.css
theme-defaults.css
src
fetch.rs
views
record.rs
+1
Cargo.lock
···
8670
8670
"humansize",
8671
8671
"jacquard",
8672
8672
"jacquard-axum",
8673
8673
+
"jacquard-lexicon",
8673
8674
"js-sys",
8674
8675
"markdown-weaver",
8675
8676
"mime-sniffer",
+1
crates/weaver-app/Cargo.toml
···
23
23
#dioxus = { version = "0.7.1", features = ["router", "fullstack"] }
24
24
weaver-common = { path = "../weaver-common" }
25
25
jacquard = { workspace = true, features = ["streaming"] }
26
26
+
jacquard-lexicon = { workspace = true }
26
27
jacquard-axum = { workspace = true, optional = true }
27
28
weaver-api = { path = "../weaver-api", features = ["streaming"] }
28
29
markdown-weaver = { workspace = true }
+125
-13
crates/weaver-app/assets/styling/record-view.css
···
35
35
}
36
36
37
37
.metadata-label {
38
38
-
color: var(--color-muted);
38
38
+
color: var(--color-subtle);
39
39
font-size: 0.85rem;
40
40
text-transform: uppercase;
41
41
letter-spacing: 0.1em;
···
75
75
border-bottom: 1px solid var(--color-border);
76
76
margin-bottom: 1.5rem;
77
77
margin-top: 1.5rem;
78
78
+
align-items: center;
78
79
}
79
80
80
81
.tab-button {
···
83
84
border: none;
84
85
padding: 0.5rem 1rem;
85
86
cursor: pointer;
86
86
-
color: var(--color-muted);
87
87
+
color: var(--color-subtle);
87
88
text-transform: uppercase;
88
89
font-size: 0.9rem;
89
90
letter-spacing: 0.1em;
···
101
102
border-bottom-color: var(--color-primary);
102
103
}
103
104
105
105
+
.tab-button.edit-button {
106
106
+
margin-left: auto;
107
107
+
}
108
108
+
109
109
+
.action-buttons-group {
110
110
+
margin-left: auto;
111
111
+
display: flex;
112
112
+
gap: 0;
113
113
+
align-items: center;
114
114
+
}
115
115
+
116
116
+
.tab-button.action-button-danger {
117
117
+
color: var(--color-error, #ff6b6b);
118
118
+
}
119
119
+
120
120
+
.tab-button.action-button-danger:hover {
121
121
+
color: var(--color-error, #ff5252);
122
122
+
border-bottom-color: var(--color-error, #ff6b6b);
123
123
+
}
124
124
+
125
125
+
.dropdown-wrapper {
126
126
+
position: relative;
127
127
+
display: inline-block;
128
128
+
}
129
129
+
130
130
+
.dropdown-menu {
131
131
+
position: absolute;
132
132
+
top: 100%;
133
133
+
left: 0;
134
134
+
background: var(--color-background);
135
135
+
border: 1px solid var(--color-border);
136
136
+
border-radius: 4px;
137
137
+
margin-top: 0.25rem;
138
138
+
z-index: 100;
139
139
+
min-width: 150px;
140
140
+
}
141
141
+
142
142
+
.dropdown-menu button {
143
143
+
display: block;
144
144
+
width: 100%;
145
145
+
padding: 0.5rem 1rem;
146
146
+
background: transparent;
147
147
+
border: none;
148
148
+
text-align: left;
149
149
+
cursor: pointer;
150
150
+
color: var(--color-text);
151
151
+
font-family: var(--font-mono);
152
152
+
}
153
153
+
154
154
+
.dropdown-menu button:hover {
155
155
+
background: var(--color-hover, rgba(255, 255, 255, 0.05));
156
156
+
}
157
157
+
104
158
.tab-content {
105
159
min-height: 300px;
106
160
}
···
118
172
119
173
padding-right: 1rem;
120
174
border-left: 2px solid var(--color-secondary);
121
121
-
border-bottom: 1px dashed var(--color-muted);
175
175
+
border-bottom: 1px dashed var(--color-subtle);
122
176
}
123
177
124
178
.field-label {
···
129
183
}
130
184
131
185
.path-prefix {
132
132
-
color: var(--color-muted);
133
133
-
opacity: 0.7;
186
186
+
color: var(--color-subtle);
134
187
}
135
188
136
189
.path-final {
···
223
276
224
277
.string-type-tag {
225
278
font-size: 0.7rem;
226
226
-
color: var(--color-muted);
279
279
+
color: var(--color-subtle);
227
280
text-transform: uppercase;
228
281
letter-spacing: 0.05em;
229
282
}
···
256
309
257
310
/* NSID highlighting */
258
311
.nsid-dot {
259
259
-
color: var(--color-muted);
312
312
+
color: var(--color-subtle);
260
313
opacity: 0.6;
261
314
}
262
315
···
274
327
275
328
/* DID highlighting */
276
329
.did-scheme {
277
277
-
color: var(--color-muted);
330
330
+
color: var(--color-subtle);
278
331
opacity: 0.7;
279
332
}
280
333
···
294
347
295
348
/* Handle highlighting */
296
349
.handle-dot {
297
297
-
color: var(--color-muted);
350
350
+
color: var(--color-subtle);
298
351
opacity: 0.6;
299
352
}
300
353
···
309
362
310
363
/* AT URI highlighting */
311
364
.aturi-scheme {
312
312
-
color: var(--color-muted);
365
365
+
color: var(--color-subtle);
313
366
opacity: 0.7;
314
367
}
315
368
···
318
371
}
319
372
320
373
.aturi-slash {
321
321
-
color: var(--color-muted);
374
374
+
color: var(--color-subtle);
322
375
opacity: 0.6;
323
376
}
324
377
···
332
385
333
386
/* URI highlighting */
334
387
.uri-scheme {
335
335
-
color: var(--color-muted);
388
388
+
color: var(--color-subtle);
336
389
opacity: 0.7;
337
390
font-weight: 500;
338
391
}
339
392
340
393
.uri-separator {
341
341
-
color: var(--color-muted);
394
394
+
color: var(--color-subtle);
342
395
opacity: 0.6;
343
396
}
344
397
···
349
402
.uri-path {
350
403
color: var(--color-secondary);
351
404
}
405
405
+
406
406
+
/* JSON Editor */
407
407
+
.json-editor {
408
408
+
display: flex;
409
409
+
gap: 1.5rem;
410
410
+
}
411
411
+
412
412
+
.json-textarea {
413
413
+
flex: 1;
414
414
+
font-family: var(--font-mono);
415
415
+
font-size: 0.9rem;
416
416
+
padding: 1rem;
417
417
+
background: var(--color-background-alt, rgba(0, 0, 0, 0.2));
418
418
+
border: 1px solid var(--color-border);
419
419
+
color: var(--color-text);
420
420
+
resize: vertical;
421
421
+
line-height: 1.5;
422
422
+
}
423
423
+
424
424
+
.json-textarea:focus {
425
425
+
outline: none;
426
426
+
border-color: var(--color-primary);
427
427
+
}
428
428
+
429
429
+
.validation-panel {
430
430
+
flex: 0 0 300px;
431
431
+
font-family: var(--font-mono);
432
432
+
font-size: 0.85rem;
433
433
+
padding: 1rem;
434
434
+
background: var(--color-background-alt, rgba(0, 0, 0, 0.2));
435
435
+
border: 1px solid var(--color-border);
436
436
+
overflow-y: auto;
437
437
+
align-self: flex-start;
438
438
+
}
439
439
+
440
440
+
.parse-error,
441
441
+
.validation-errors {
442
442
+
color: var(--color-error, #ff6b6b);
443
443
+
margin-top: 0.5rem;
444
444
+
}
445
445
+
446
446
+
.parse-success,
447
447
+
.validation-success {
448
448
+
color: var(--color-success, #51cf66);
449
449
+
}
450
450
+
451
451
+
.validation-errors h4 {
452
452
+
font-size: 0.9rem;
453
453
+
font-weight: 600;
454
454
+
margin-bottom: 0.5rem;
455
455
+
color: var(--color-text);
456
456
+
}
457
457
+
458
458
+
.validation-errors .error {
459
459
+
padding: 0.25rem 0;
460
460
+
border-left: 2px solid var(--color-error, #ff6b6b);
461
461
+
padding-left: 0.5rem;
462
462
+
margin: 0.25rem 0;
463
463
+
}
+2
-2
crates/weaver-app/assets/styling/theme-defaults.css
···
46
46
--color-text: #575279;
47
47
--color-muted: #9893a5;
48
48
--color-subtle: #797593;
49
49
-
--color-emphasis: #575279;
49
49
+
--color-emphasis: #403d52;
50
50
--color-primary: #907aa9;
51
51
--color-secondary: #56949f;
52
52
--color-tertiary: #286983;
53
53
--color-error: #b4637a;
54
54
--color-warning: #ea9d34;
55
55
--color-success: #286983;
56
56
-
--color-border: #dfdad9;
56
56
+
--color-border: #908caa;
57
57
--color-link: #d7827e;
58
58
--color-highlight: #cecacd;
59
59
+38
crates/weaver-app/src/fetch.rs
···
9
9
use jacquard::identity::resolver::DidDocResponse;
10
10
use jacquard::identity::resolver::IdentityError;
11
11
use jacquard::identity::resolver::ResolverOptions;
12
12
+
use jacquard::identity::lexicon_resolver::{LexiconSchemaResolver, ResolvedLexiconSchema, LexiconResolutionError};
12
13
use jacquard::identity::JacquardResolver;
14
14
+
use jacquard::types::string::Nsid;
13
15
use jacquard::oauth::client::OAuthClient;
14
16
use jacquard::oauth::client::OAuthSession;
15
17
use jacquard::prelude::*;
···
253
255
did: &Did<'_>,
254
256
) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> {
255
257
self.oauth_client.client.resolve_did_doc(did)
258
258
+
}
259
259
+
}
260
260
+
261
261
+
impl LexiconSchemaResolver for Client {
262
262
+
#[cfg(not(target_arch = "wasm32"))]
263
263
+
async fn resolve_lexicon_schema(
264
264
+
&self,
265
265
+
nsid: &Nsid<'_>,
266
266
+
) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
267
267
+
self.oauth_client.client.resolve_lexicon_schema(nsid).await
268
268
+
}
269
269
+
270
270
+
#[cfg(target_arch = "wasm32")]
271
271
+
async fn resolve_lexicon_schema(
272
272
+
&self,
273
273
+
nsid: &Nsid<'_>,
274
274
+
) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
275
275
+
self.oauth_client.client.resolve_lexicon_schema(nsid).await
256
276
}
257
277
}
258
278
···
741
761
did: &Did<'_>,
742
762
) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> {
743
763
self.client.resolve_did_doc(did)
764
764
+
}
765
765
+
}
766
766
+
767
767
+
impl LexiconSchemaResolver for CachedFetcher {
768
768
+
#[cfg(not(target_arch = "wasm32"))]
769
769
+
async fn resolve_lexicon_schema(
770
770
+
&self,
771
771
+
nsid: &Nsid<'_>,
772
772
+
) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
773
773
+
self.client.resolve_lexicon_schema(nsid).await
774
774
+
}
775
775
+
776
776
+
#[cfg(target_arch = "wasm32")]
777
777
+
async fn resolve_lexicon_schema(
778
778
+
&self,
779
779
+
nsid: &Nsid<'_>,
780
780
+
) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
781
781
+
self.client.resolve_lexicon_schema(nsid).await
744
782
}
745
783
}
746
784
+436
-6
crates/weaver-app/src/views/record.rs
···
1
1
+
use crate::Route;
2
2
+
use crate::auth::AuthState;
3
3
+
use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle};
1
4
use crate::fetch::CachedFetcher;
2
5
use dioxus::prelude::*;
6
6
+
use dioxus_logger::tracing::*;
3
7
use humansize::format_size;
8
8
+
use jacquard::prelude::*;
9
9
+
use jacquard::smol_str::ToSmolStr;
4
10
use jacquard::{
5
11
client::AgentSessionExt,
6
12
common::{Data, IntoStatic},
13
13
+
identity::lexicon_resolver::LexiconSchemaResolver,
7
14
smol_str::SmolStr,
8
8
-
types::aturi::AtUri,
15
15
+
types::{aturi::AtUri, ident::AtIdentifier, string::Nsid},
16
16
+
};
17
17
+
use weaver_api::com_atproto::repo::{
18
18
+
create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord,
9
19
};
10
20
use weaver_renderer::{code_pretty::highlight_code, css::generate_default_css};
11
21
···
31
41
}
32
42
let uri = use_signal(|| at_uri.unwrap());
33
43
let mut view_mode = use_signal(|| ViewMode::Pretty);
34
34
-
let record = use_resource(move || {
35
35
-
let client = fetcher.get_client();
44
44
+
let mut edit_mode = use_signal(|| false);
45
45
+
let navigator = use_navigator();
36
46
47
47
+
let client = fetcher.get_client();
48
48
+
let record = use_resource(move || {
49
49
+
let client = client.clone();
37
50
async move { client.fetch_record_slingshot(&uri()).await }
38
51
});
52
52
+
53
53
+
// Check ownership for edit access
54
54
+
let auth_state = use_context::<Signal<AuthState>>();
55
55
+
let is_owner = use_memo(move || {
56
56
+
let auth = auth_state();
57
57
+
if !auth.is_authenticated() {
58
58
+
return false;
59
59
+
}
60
60
+
61
61
+
// authority() returns &AtIdentifier which can be Did or Handle
62
62
+
match uri().authority() {
63
63
+
AtIdentifier::Did(record_did) => auth.did.as_ref() == Some(record_did),
64
64
+
AtIdentifier::Handle(_) => {
65
65
+
// Can't easily check ownership for handles without async resolution
66
66
+
false
67
67
+
}
68
68
+
}
69
69
+
});
39
70
if let Some(Ok(record)) = &*record.read_unchecked() {
40
71
let record_value = record.value.clone().into_static();
72
72
+
let mut edit_data = use_signal(|| record_value.clone());
73
73
+
let nsid = use_memo(move || edit_data().type_discriminator().map(|s| s.to_string()));
41
74
let json = serde_json::to_string_pretty(&record_value).unwrap();
42
75
rsx! {
43
76
document::Stylesheet { href: asset!("/assets/styling/record-view.css") }
···
74
107
onclick: move |_| view_mode.set(ViewMode::Json),
75
108
"JSON"
76
109
}
110
110
+
if is_owner() && !edit_mode() {
111
111
+
{
112
112
+
let record_value_clone = record_value.clone();
113
113
+
rsx! {
114
114
+
button {
115
115
+
class: "tab-button edit-button",
116
116
+
onclick: move |_| {
117
117
+
edit_data.set(record_value_clone.clone());
118
118
+
edit_mode.set(true);
119
119
+
},
120
120
+
"Edit"
121
121
+
}
122
122
+
}
123
123
+
}
124
124
+
}
125
125
+
if edit_mode() {
126
126
+
{
127
127
+
let record_value_clone = record_value.clone();
128
128
+
let update_fetcher = fetcher.clone();
129
129
+
let create_fetcher = fetcher.clone();
130
130
+
let replace_fetcher = fetcher.clone();
131
131
+
rsx! {
132
132
+
ActionButtons {
133
133
+
on_update: move |_| {
134
134
+
let fetcher = update_fetcher.clone();
135
135
+
let uri = uri();
136
136
+
let data = edit_data();
137
137
+
spawn(async move {
138
138
+
if let Some((did, _)) = fetcher.session_info().await {
139
139
+
if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) {
140
140
+
let collection = Nsid::new(collection_str.as_str()).ok();
141
141
+
if let Some(collection) = collection {
142
142
+
let request = PutRecord::new()
143
143
+
.repo(AtIdentifier::Did(did))
144
144
+
.collection(collection)
145
145
+
.rkey(rkey.clone())
146
146
+
.record(data)
147
147
+
.build();
148
148
+
149
149
+
match fetcher.send(request).await {
150
150
+
Ok(_) => {
151
151
+
dioxus_logger::tracing::info!("Record updated successfully");
152
152
+
edit_mode.set(false);
153
153
+
}
154
154
+
Err(e) => {
155
155
+
dioxus_logger::tracing::error!("Failed to update record: {:?}", e);
156
156
+
}
157
157
+
}
158
158
+
}
159
159
+
}
160
160
+
}
161
161
+
});
162
162
+
},
163
163
+
on_save_new: move |_| {
164
164
+
let fetcher = create_fetcher.clone();
165
165
+
let data = edit_data();
166
166
+
let nav = navigator.clone();
167
167
+
spawn(async move {
168
168
+
if let Some((did, _)) = fetcher.session_info().await {
169
169
+
if let Some(collection_str) = data.type_discriminator() {
170
170
+
let collection = Nsid::new(collection_str).ok();
171
171
+
if let Some(collection) = collection {
172
172
+
let request = CreateRecord::new()
173
173
+
.repo(AtIdentifier::Did(did))
174
174
+
.collection(collection)
175
175
+
.record(data.clone())
176
176
+
.build();
177
177
+
178
178
+
match fetcher.send(request).await {
179
179
+
Ok(response) => {
180
180
+
if let Ok(output) = response.into_output() {
181
181
+
dioxus_logger::tracing::info!("Record created: {}", output.uri);
182
182
+
nav.push(Route::RecordView { uri: output.uri.to_smolstr() });
183
183
+
}
184
184
+
}
185
185
+
Err(e) => {
186
186
+
dioxus_logger::tracing::error!("Failed to create record: {:?}", e);
187
187
+
}
188
188
+
}
189
189
+
}
190
190
+
}
191
191
+
}
192
192
+
});
193
193
+
},
194
194
+
on_replace: move |_| {
195
195
+
let fetcher = replace_fetcher.clone();
196
196
+
let uri = uri();
197
197
+
let data = edit_data();
198
198
+
let nav = navigator.clone();
199
199
+
spawn(async move {
200
200
+
if let Some((did, _)) = fetcher.session_info().await {
201
201
+
if let Some(new_collection_str) = data.type_discriminator() {
202
202
+
let new_collection = Nsid::new(new_collection_str).ok();
203
203
+
if let Some(new_collection) = new_collection {
204
204
+
// Create new record
205
205
+
let create_req = CreateRecord::new()
206
206
+
.repo(AtIdentifier::Did(did.clone()))
207
207
+
.collection(new_collection)
208
208
+
.record(data.clone())
209
209
+
.build();
210
210
+
211
211
+
match fetcher.send(create_req).await {
212
212
+
Ok(response) => {
213
213
+
if let Ok(create_output) = response.into_output() {
214
214
+
// Delete old record
215
215
+
if let (Some(old_collection_str), Some(old_rkey)) = (uri.collection(), uri.rkey()) {
216
216
+
let old_collection = Nsid::new(old_collection_str.as_str()).ok();
217
217
+
if let Some(old_collection) = old_collection {
218
218
+
let delete_req = DeleteRecord::new()
219
219
+
.repo(AtIdentifier::Did(did))
220
220
+
.collection(old_collection)
221
221
+
.rkey(old_rkey.clone())
222
222
+
.build();
223
223
+
224
224
+
if let Err(e) = fetcher.send(delete_req).await {
225
225
+
warn!("Created new record but failed to delete old: {:?}", e);
226
226
+
}
227
227
+
}
228
228
+
}
229
229
+
230
230
+
info!("Record replaced: {}", create_output.uri);
231
231
+
nav.push(Route::RecordView { uri: create_output.uri.to_smolstr() });
232
232
+
}
233
233
+
}
234
234
+
Err(e) => {
235
235
+
error!("Failed to replace record: {:?}", e);
236
236
+
}
237
237
+
}
238
238
+
}
239
239
+
}
240
240
+
}
241
241
+
});
242
242
+
},
243
243
+
on_delete: move |_| {
244
244
+
let fetcher = fetcher.clone();
245
245
+
let uri = uri();
246
246
+
let nav = navigator.clone();
247
247
+
spawn(async move {
248
248
+
if let Some((did, _)) = fetcher.session_info().await {
249
249
+
if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) {
250
250
+
let collection = Nsid::new(collection_str.as_str()).ok();
251
251
+
if let Some(collection) = collection {
252
252
+
let request = DeleteRecord::new()
253
253
+
.repo(AtIdentifier::Did(did))
254
254
+
.collection(collection)
255
255
+
.rkey(rkey.clone())
256
256
+
.build();
257
257
+
258
258
+
match fetcher.send(request).await {
259
259
+
Ok(_) => {
260
260
+
info!("Record deleted");
261
261
+
nav.push(Route::Home {});
262
262
+
}
263
263
+
Err(e) => {
264
264
+
error!("Failed to delete record: {:?}", e);
265
265
+
}
266
266
+
}
267
267
+
}
268
268
+
}
269
269
+
}
270
270
+
});
271
271
+
},
272
272
+
on_cancel: move |_| {
273
273
+
edit_data.set(record_value_clone.clone());
274
274
+
edit_mode.set(false);
275
275
+
},
276
276
+
}
277
277
+
}
278
278
+
}
279
279
+
}
77
280
}
78
281
div {
79
282
class: "tab-content",
80
80
-
match view_mode() {
81
81
-
ViewMode::Pretty => rsx! {
283
283
+
match (view_mode(), edit_mode()) {
284
284
+
(ViewMode::Pretty, false) => rsx! {
82
285
PrettyRecordView { record: record_value.clone(), uri: uri().clone() }
83
286
},
84
84
-
ViewMode::Json => rsx! {
287
287
+
(ViewMode::Json, false) => rsx! {
85
288
CodeView {
86
289
code: use_signal(|| json.clone()),
87
290
lang: Some("json".to_string()),
88
291
}
89
292
},
293
293
+
(ViewMode::Pretty, true) => rsx! {
294
294
+
div { "Pretty editor not yet implemented" }
295
295
+
},
296
296
+
(ViewMode::Json, true) => rsx! {
297
297
+
JsonEditor {
298
298
+
data: edit_data,
299
299
+
nsid: nsid,
300
300
+
}
301
301
+
},
90
302
}
91
303
}
92
304
}
···
440
652
class: Signal<String>,
441
653
code: ReadSignal<String>,
442
654
lang: Option<String>,
655
655
+
}
656
656
+
657
657
+
#[component]
658
658
+
fn JsonEditor(data: Signal<Data<'static>>, nsid: ReadSignal<Option<String>>) -> Element {
659
659
+
let mut json_text =
660
660
+
use_signal(|| serde_json::to_string_pretty(&*data.read()).unwrap_or_default());
661
661
+
let mut parse_error = use_signal(|| None::<String>);
662
662
+
663
663
+
let height = use_memo(move || {
664
664
+
let line_count = json_text().lines().count();
665
665
+
let min_lines = 10;
666
666
+
let lines = line_count.max(min_lines);
667
667
+
// line-height is 1.5, font-size is 0.9rem (approx 14.4px), so each line is ~21.6px
668
668
+
// Add padding (1rem top + 1rem bottom = 2rem = 32px)
669
669
+
format!("{}px", lines * 22 + 32)
670
670
+
});
671
671
+
672
672
+
let fetcher = use_context::<CachedFetcher>();
673
673
+
674
674
+
let validation = use_resource(move || {
675
675
+
let text = json_text();
676
676
+
let nsid_val = nsid();
677
677
+
let fetcher = fetcher.clone();
678
678
+
679
679
+
async move {
680
680
+
// Only validate if we have an NSID
681
681
+
let nsid_str = nsid_val?;
682
682
+
683
683
+
// Parse JSON to Data
684
684
+
let parsed = match serde_json::from_str::<Data>(&text) {
685
685
+
Ok(val) => val.into_static(),
686
686
+
Err(e) => {
687
687
+
return Some((None, Some(e.to_string())));
688
688
+
}
689
689
+
};
690
690
+
691
691
+
// Resolve lexicon if needed
692
692
+
let registry = jacquard_lexicon::schema::SchemaRegistry::from_inventory();
693
693
+
if registry.get(&nsid_str).is_none() {
694
694
+
let nsid_str = nsid_str.split('#').next();
695
695
+
if let Some(Ok(nsid_parsed)) = nsid_str.map(|s| Nsid::new(s)) {
696
696
+
if let Ok(schema) = fetcher.resolve_lexicon_schema(&nsid_parsed).await {
697
697
+
registry.insert(nsid_parsed.to_smolstr(), schema.doc);
698
698
+
}
699
699
+
}
700
700
+
}
701
701
+
702
702
+
// Validate
703
703
+
let validator = jacquard_lexicon::validation::SchemaValidator::from_registry(registry);
704
704
+
let result = validator.validate_by_nsid(&nsid_str, &parsed);
705
705
+
706
706
+
Some((Some(result), None))
707
707
+
}
708
708
+
});
709
709
+
710
710
+
rsx! {
711
711
+
div { class: "json-editor",
712
712
+
textarea {
713
713
+
class: "json-textarea",
714
714
+
style: "height: {height};",
715
715
+
value: "{json_text}",
716
716
+
oninput: move |evt| {
717
717
+
json_text.set(evt.value());
718
718
+
// Update data signal on successful parse
719
719
+
if let Ok(parsed) = serde_json::from_str::<Data>(&evt.value()) {
720
720
+
data.set(parsed.into_static());
721
721
+
}
722
722
+
},
723
723
+
}
724
724
+
725
725
+
ValidationPanel {
726
726
+
validation: validation,
727
727
+
}
728
728
+
}
729
729
+
}
730
730
+
}
731
731
+
732
732
+
#[component]
733
733
+
fn ActionButtons(
734
734
+
on_update: EventHandler<()>,
735
735
+
on_save_new: EventHandler<()>,
736
736
+
on_replace: EventHandler<()>,
737
737
+
on_delete: EventHandler<()>,
738
738
+
on_cancel: EventHandler<()>,
739
739
+
) -> Element {
740
740
+
let mut show_save_dropdown = use_signal(|| false);
741
741
+
let mut show_replace_warning = use_signal(|| false);
742
742
+
let mut show_delete_confirm = use_signal(|| false);
743
743
+
744
744
+
rsx! {
745
745
+
div { class: "action-buttons-group",
746
746
+
button {
747
747
+
class: "tab-button action-button",
748
748
+
onclick: move |_| on_update.call(()),
749
749
+
"Update"
750
750
+
}
751
751
+
752
752
+
div { class: "dropdown-wrapper",
753
753
+
button {
754
754
+
class: "tab-button action-button",
755
755
+
onclick: move |_| show_save_dropdown.toggle(),
756
756
+
"Save as New ▼"
757
757
+
}
758
758
+
if show_save_dropdown() {
759
759
+
div { class: "dropdown-menu",
760
760
+
button {
761
761
+
onclick: move |_| {
762
762
+
show_save_dropdown.set(false);
763
763
+
on_save_new.call(());
764
764
+
},
765
765
+
"Save as New"
766
766
+
}
767
767
+
button {
768
768
+
onclick: move |_| {
769
769
+
show_save_dropdown.set(false);
770
770
+
show_replace_warning.set(true);
771
771
+
},
772
772
+
"Replace"
773
773
+
}
774
774
+
}
775
775
+
}
776
776
+
}
777
777
+
778
778
+
if show_replace_warning() {
779
779
+
div { class: "inline-warning",
780
780
+
"⚠️ This will delete the current record and create a new one with a different rkey. "
781
781
+
button {
782
782
+
onclick: move |_| {
783
783
+
show_replace_warning.set(false);
784
784
+
on_replace.call(());
785
785
+
},
786
786
+
"Yes"
787
787
+
}
788
788
+
button {
789
789
+
onclick: move |_| show_replace_warning.set(false),
790
790
+
"No"
791
791
+
}
792
792
+
}
793
793
+
}
794
794
+
795
795
+
button {
796
796
+
class: "tab-button action-button action-button-danger",
797
797
+
onclick: move |_| show_delete_confirm.set(true),
798
798
+
"Delete"
799
799
+
}
800
800
+
801
801
+
DialogRoot {
802
802
+
open: Some(show_delete_confirm()),
803
803
+
on_open_change: move |open: bool| {
804
804
+
show_delete_confirm.set(open);
805
805
+
},
806
806
+
DialogContent {
807
807
+
DialogTitle { "Delete Record?" }
808
808
+
DialogDescription {
809
809
+
"This action cannot be undone."
810
810
+
}
811
811
+
div { class: "dialog-actions",
812
812
+
button {
813
813
+
onclick: move |_| {
814
814
+
show_delete_confirm.set(false);
815
815
+
on_delete.call(());
816
816
+
},
817
817
+
"Delete"
818
818
+
}
819
819
+
button {
820
820
+
onclick: move |_| show_delete_confirm.set(false),
821
821
+
"Cancel"
822
822
+
}
823
823
+
}
824
824
+
}
825
825
+
}
826
826
+
827
827
+
button {
828
828
+
class: "tab-button action-button",
829
829
+
onclick: move |_| on_cancel.call(()),
830
830
+
"Cancel"
831
831
+
}
832
832
+
}
833
833
+
}
834
834
+
}
835
835
+
836
836
+
#[component]
837
837
+
fn ValidationPanel(
838
838
+
validation: Resource<
839
839
+
Option<(
840
840
+
Option<jacquard_lexicon::validation::ValidationResult>,
841
841
+
Option<String>,
842
842
+
)>,
843
843
+
>,
844
844
+
) -> Element {
845
845
+
rsx! {
846
846
+
div { class: "validation-panel",
847
847
+
if let Some(Some((result_opt, parse_error_opt))) = validation.read().as_ref() {
848
848
+
if let Some(parse_err) = parse_error_opt {
849
849
+
div { class: "parse-error",
850
850
+
"❌ Invalid JSON: {parse_err}"
851
851
+
}
852
852
+
} else {
853
853
+
div { class: "parse-success", "✓ Valid JSON syntax" }
854
854
+
}
855
855
+
856
856
+
if let Some(result) = result_opt {
857
857
+
if result.is_valid() {
858
858
+
div { class: "validation-success", "✓ Record is valid" }
859
859
+
} else {
860
860
+
div { class: "validation-errors",
861
861
+
h4 { "Validation Errors:" }
862
862
+
for error in result.all_errors() {
863
863
+
div { class: "error", "❌ {error}" }
864
864
+
}
865
865
+
}
866
866
+
}
867
867
+
}
868
868
+
} else {
869
869
+
div { "Validating..." }
870
870
+
}
871
871
+
}
872
872
+
}
443
873
}
444
874
445
875
/// Render some text as markdown.