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