atproto blogging
1use crate::Route;
2use crate::components::accordion::{Accordion, AccordionContent, AccordionItem, AccordionTrigger};
3use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle};
4use crate::components::record_view::{PathLabel, SchemaView, ViewMode};
5use crate::fetch::Fetcher;
6use crate::record_utils::{create_array_item_default, infer_data_from_text, try_parse_as_type};
7use dioxus::prelude::{FormData, *};
8use http::StatusCode;
9use humansize::format_size;
10use jacquard::api::com_atproto::repo::get_record::GetRecordOutput;
11use jacquard::bytes::Bytes;
12use jacquard::client::AgentError;
13use jacquard::{atproto, prelude::*};
14use jacquard::{
15 client::AgentSessionExt,
16 common::{Data, IntoStatic},
17 types::{aturi::AtUri, ident::AtIdentifier, string::Nsid},
18};
19use jacquard_lexicon::lexicon::LexiconDoc;
20use jacquard_lexicon::validation::ValidationResult;
21use mime_sniffer::MimeTypeSniffer;
22use weaver_api::com_atproto::repo::{
23 create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord,
24};
25// ============================================================================
26// Pretty Editor: Component Hierarchy
27// ============================================================================
28
29/// Main dispatcher - routes to specific field editors based on Data type
30#[component]
31fn EditableDataView(
32 root: Signal<Data<'static>>,
33 path: String,
34 did: String,
35 #[props(default)] remove_button: Option<Element>,
36) -> Element {
37 let path_for_memo = path.clone();
38 let root_read = root.read();
39
40 match root_read
41 .get_at_path(&path_for_memo)
42 .map(|d| d.clone().into_static())
43 {
44 Some(Data::Object(_)) => {
45 rsx! { EditableObjectField { root, path: path.clone(), did, remove_button } }
46 }
47 Some(Data::Array(_)) => rsx! { EditableArrayField { root, path: path.clone(), did } },
48 Some(Data::String(_)) => {
49 rsx! { EditableStringField { root, path: path.clone(), remove_button } }
50 }
51 Some(Data::Integer(_)) => {
52 rsx! { EditableIntegerField { root, path: path.clone(), remove_button } }
53 }
54 Some(Data::Boolean(_)) => {
55 rsx! { EditableBooleanField { root, path: path.clone(), remove_button } }
56 }
57 Some(Data::Null) => rsx! { EditableNullField { root, path: path.clone(), remove_button } },
58 Some(Data::Blob(_)) => {
59 rsx! { EditableBlobField { root, path: path.clone(), did, remove_button } }
60 }
61 Some(Data::Bytes(_)) => {
62 rsx! { EditableBytesField { root, path: path.clone(), remove_button } }
63 }
64 Some(Data::CidLink(_)) => {
65 rsx! { EditableCidLinkField { root, path: path.clone(), remove_button } }
66 }
67
68 None => rsx! { div { class: "field-error", "❌ Path not found: {path}" } },
69 }
70}
71
72// ============================================================================
73// Primitive Field Editors
74// ============================================================================
75
76/// String field with type preservation
77#[component]
78fn EditableStringField(
79 root: Signal<Data<'static>>,
80 path: String,
81 #[props(default)] remove_button: Option<Element>,
82) -> Element {
83 use jacquard::types::LexiconStringType;
84
85 let path_for_text = path.clone();
86 let path_for_type = path.clone();
87
88 // Get current string value
89 let current_text = use_memo(move || {
90 root.read()
91 .get_at_path(&path_for_text)
92 .and_then(|d| d.as_str())
93 .map(|s| s.to_string())
94 .unwrap_or_default()
95 });
96
97 // Get string type (Copy, cheap to store)
98 let string_type = use_memo(move || {
99 root.read()
100 .get_at_path(&path_for_type)
101 .and_then(|d| match d {
102 Data::String(s) => Some(s.string_type()),
103 _ => None,
104 })
105 .unwrap_or(LexiconStringType::String)
106 });
107
108 // Local state for invalid input
109 let mut input_text = use_signal(|| current_text());
110 let mut parse_error = use_signal(|| None::<String>);
111
112 // Sync input when current changes
113 use_effect(move || {
114 input_text.set(current_text());
115 });
116
117 let path_for_mutation = path.clone();
118 let handle_input = move |evt: Event<FormData>| {
119 let new_text = evt.value();
120 input_text.set(new_text.clone());
121
122 match try_parse_as_type(&new_text, string_type()) {
123 Ok(new_atproto_str) => {
124 parse_error.set(None);
125 let mut new_data = root.read().clone();
126 new_data.set_at_path(&path_for_mutation, Data::String(new_atproto_str));
127 root.set(new_data);
128 }
129 Err(e) => {
130 parse_error.set(Some(e));
131 }
132 }
133 };
134
135 let type_label = format!("{:?}", string_type()).to_lowercase();
136 let is_plain_string = string_type() == LexiconStringType::String;
137
138 // Dynamic width based on content length
139 let input_width = use_memo(move || {
140 let len = input_text().len();
141 let min_width = match string_type() {
142 LexiconStringType::Cid => 60,
143 LexiconStringType::Nsid => 40,
144 LexiconStringType::Did => 50,
145 LexiconStringType::AtUri => 50,
146 _ => 20,
147 };
148 format!("{}ch", len.max(min_width))
149 });
150
151 rsx! {
152 div { class: "record-field",
153 div { class: "field-header",
154 PathLabel { path: path.clone() }
155 if type_label != "string" {
156 span { class: "string-type-tag", " [{type_label}]" }
157 }
158 {remove_button}
159 }
160 if is_plain_string {
161 textarea {
162 value: "{input_text}",
163 oninput: handle_input,
164 class: if parse_error().is_some() { "invalid" } else { "" },
165 rows: "1",
166 }
167 } else {
168 input {
169 r#type: "text",
170 value: "{input_text}",
171 style: "width: {input_width}",
172 oninput: handle_input,
173 class: if parse_error().is_some() { "invalid" } else { "" },
174 }
175 }
176 if let Some(err) = parse_error() {
177 span { class: "field-error", " ❌ {err}" }
178 }
179 }
180 }
181}
182
183/// Integer field with validation
184#[component]
185fn EditableIntegerField(
186 root: Signal<Data<'static>>,
187 path: String,
188 #[props(default)] remove_button: Option<Element>,
189) -> Element {
190 let path_for_memo = path.clone();
191 let current_value = use_memo(move || {
192 root.read()
193 .get_at_path(&path_for_memo)
194 .and_then(|d| d.as_integer())
195 .unwrap_or(0)
196 });
197
198 let mut input_text = use_signal(|| current_value().to_string());
199 let mut parse_error = use_signal(|| None::<String>);
200
201 use_effect(move || {
202 input_text.set(current_value().to_string());
203 });
204
205 let path_for_mutation = path.clone();
206
207 rsx! {
208 div { class: "record-field",
209 div { class: "field-header",
210 PathLabel { path: path.clone() }
211 {remove_button}
212 }
213 input {
214 r#type: "number",
215 value: "{input_text}",
216 oninput: move |evt| {
217 let text = evt.value();
218 input_text.set(text.clone());
219
220 match text.parse::<i64>() {
221 Ok(num) => {
222 parse_error.set(None);
223 let mut data_edit = root.write_unchecked();
224 data_edit.set_at_path(&path_for_mutation, Data::Integer(num));
225 }
226 Err(_) => {
227 parse_error.set(Some("Must be a valid integer".to_string()));
228 }
229 }
230 }
231 }
232 if let Some(err) = parse_error() {
233 span { class: "field-error", " ❌ {err}" }
234 }
235 }
236 }
237}
238
239/// Boolean field (toggle button)
240#[component]
241fn EditableBooleanField(
242 root: Signal<Data<'static>>,
243 path: String,
244 #[props(default)] remove_button: Option<Element>,
245) -> Element {
246 let path_for_memo = path.clone();
247 let current_value = use_memo(move || {
248 root.read()
249 .get_at_path(&path_for_memo)
250 .and_then(|d| d.as_boolean())
251 .unwrap_or(false)
252 });
253
254 let path_for_mutation = path.clone();
255 rsx! {
256 div { class: "record-field",
257 div { class: "field-header",
258 PathLabel { path: path.clone() }
259 {remove_button}
260 }
261 button {
262 class: if current_value() { "boolean-toggle boolean-toggle-true" } else { "boolean-toggle boolean-toggle-false" },
263 onclick: move |_| {
264 root.with_mut(|data| {
265 if let Some(target) = data.get_at_path_mut(path_for_mutation.as_str()) {
266 if let Some(bool_val) = target.as_boolean() {
267 *target = Data::Boolean(!bool_val);
268 }
269 }
270 });
271 },
272 "{current_value()}"
273 }
274 }
275 }
276}
277
278/// Null field with type inference
279#[component]
280fn EditableNullField(
281 root: Signal<Data<'static>>,
282 path: String,
283 #[props(default)] remove_button: Option<Element>,
284) -> Element {
285 let mut input_text = use_signal(|| String::new());
286 let mut parse_error = use_signal(|| None::<String>);
287
288 let path_for_mutation = path.clone();
289 rsx! {
290 div { class: "record-field",
291 div { class: "field-header",
292 PathLabel { path: path.clone() }
293 span { class: "field-value muted", "null" }
294 {remove_button}
295 }
296 input {
297 r#type: "text",
298 placeholder: "Enter value (or {{}}, [], true, 123)...",
299 value: "{input_text}",
300 oninput: move |evt| {
301 input_text.set(evt.value());
302 },
303 onkeydown: move |evt| {
304 use dioxus::prelude::keyboard_types::Key;
305 if evt.key() == Key::Enter {
306 let text = input_text();
307 match infer_data_from_text(&text) {
308 Ok(new_value) => {
309 root.with_mut(|data| {
310 if let Some(target) = data.get_at_path_mut(path_for_mutation.as_str()) {
311 *target = new_value;
312 }
313 });
314 input_text.set(String::new());
315 parse_error.set(None);
316 }
317 Err(e) => {
318 parse_error.set(Some(e));
319 }
320 }
321 }
322 }
323 }
324 if let Some(err) = parse_error() {
325 span { class: "field-error", " ❌ {err}" }
326 }
327 }
328 }
329}
330
331/// Blob field - shows CID, size (editable), mime type (read-only), file upload
332#[component]
333fn EditableBlobField(
334 root: Signal<Data<'static>>,
335 path: String,
336 did: String,
337 #[props(default)] remove_button: Option<Element>,
338) -> Element {
339 let path_for_memo = path.clone();
340 let blob_data = use_memo(move || {
341 root.read()
342 .get_at_path(&path_for_memo)
343 .and_then(|d| match d {
344 Data::Blob(blob) => Some((
345 blob.r#ref.to_string(),
346 blob.size,
347 blob.mime_type.as_str().to_string(),
348 )),
349 _ => None,
350 })
351 });
352
353 let mut cid_input = use_signal(|| String::new());
354 let mut size_input = use_signal(|| String::new());
355 let mut cid_error = use_signal(|| None::<String>);
356 let mut size_error = use_signal(|| None::<String>);
357 let mut uploading = use_signal(|| false);
358 let mut upload_error = use_signal(|| None::<String>);
359 let mut preview_data_url = use_signal(|| None::<String>);
360
361 // Sync inputs when blob data changes
362 use_effect(move || {
363 if let Some((cid, size, _)) = blob_data() {
364 cid_input.set(cid);
365 size_input.set(size.to_string());
366 }
367 });
368
369 let fetcher = use_context::<Fetcher>();
370 let path_for_upload = path.clone();
371 let handle_file = move |evt: Event<FormData>| {
372 let fetcher = fetcher.clone();
373 let path_upload_clone = path_for_upload.clone();
374 spawn(async move {
375 uploading.set(true);
376 upload_error.set(None);
377
378 let files = evt.files();
379 for file_data in files {
380 match file_data.read_bytes().await {
381 Ok(bytes_data) => {
382 // Convert to jacquard Bytes and sniff MIME type
383 let bytes = Bytes::from(bytes_data.to_vec());
384 let mime_str = bytes
385 .sniff_mime_type()
386 .unwrap_or("application/octet-stream");
387 let mime_type = jacquard::types::blob::MimeType::new_owned(mime_str);
388
389 // Create data URL for immediate preview if it's an image
390 if mime_str.starts_with("image/") {
391 let base64_data = base64::Engine::encode(
392 &base64::engine::general_purpose::STANDARD,
393 &bytes,
394 );
395 let data_url = format!("data:{};base64,{}", mime_str, base64_data);
396 preview_data_url.set(Some(data_url.clone()));
397
398 // Try to decode dimensions and populate aspectRatio field
399 #[cfg(target_arch = "wasm32")]
400 {
401 let path_clone = path_upload_clone.clone();
402 spawn(async move {
403 if let Some((width, height)) =
404 decode_image_dimensions(&data_url).await
405 {
406 populate_aspect_ratio(
407 root,
408 &path_clone,
409 width as i64,
410 height as i64,
411 );
412 }
413 });
414 }
415 }
416
417 // Upload blob
418 let client = fetcher.get_client();
419 match client.upload_blob(bytes, mime_type).await {
420 Ok(new_blob) => {
421 // Update blob in record
422 let path_ref = path_upload_clone.clone();
423 root.with_mut(|record_data| {
424 if let Some(Data::Blob(blob)) =
425 record_data.get_at_path_mut(&path_ref)
426 {
427 *blob = new_blob;
428 }
429 });
430 upload_error.set(None);
431 }
432 Err(e) => {
433 upload_error.set(Some(format!("Upload failed: {:?}", e)));
434 }
435 }
436 }
437 Err(e) => {
438 upload_error.set(Some(format!("Failed to read file: {}", e)));
439 }
440 }
441 }
442
443 uploading.set(false);
444 });
445 };
446
447 let path_for_cid = path.clone();
448 let handle_cid_change = move |evt: Event<FormData>| {
449 let text = evt.value();
450 cid_input.set(text.clone());
451
452 match jacquard::types::cid::CidLink::new_owned(text.as_bytes()) {
453 Ok(new_cid_link) => {
454 cid_error.set(None);
455 root.with_mut(|data| {
456 if let Some(Data::Blob(blob)) = data.get_at_path_mut(&path_for_cid) {
457 blob.r#ref = new_cid_link;
458 }
459 });
460 }
461 Err(_) => {
462 cid_error.set(Some("Invalid CID format".to_string()));
463 }
464 }
465 };
466
467 let path_for_size = path.clone();
468 let handle_size_change = move |evt: Event<FormData>| {
469 let text = evt.value();
470 size_input.set(text.clone());
471
472 match text.parse::<usize>() {
473 Ok(new_size) => {
474 size_input.set(format_size(new_size, humansize::BINARY));
475 size_error.set(None);
476 root.with_mut(|data| {
477 if let Some(Data::Blob(blob)) = data.get_at_path_mut(&path_for_size) {
478 blob.size = new_size;
479 }
480 });
481 }
482 Err(_) => {
483 size_error.set(Some("Must be a non-negative integer".to_string()));
484 }
485 }
486 };
487
488 let placeholder_cid = "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
489 let is_placeholder = blob_data()
490 .map(|(cid, _, _)| cid == placeholder_cid)
491 .unwrap_or(true);
492 let is_image = blob_data()
493 .map(|(_, _, mime)| mime.starts_with("image/"))
494 .unwrap_or(false);
495
496 // Use preview data URL if available (fresh upload), otherwise CDN
497 let image_url = if let Some(data_url) = preview_data_url() {
498 Some(data_url)
499 } else if !is_placeholder && is_image {
500 blob_data().map(|(cid, _, mime)| {
501 let format = mime.strip_prefix("image/").unwrap_or("jpeg");
502 format!(
503 "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}",
504 did, cid, format
505 )
506 })
507 } else {
508 None
509 };
510
511 rsx! {
512 div { class: "record-field blob-field",
513 div { class: "field-header",
514 PathLabel { path: path.clone() }
515 span { class: "string-type-tag", " [blob]" }
516 {remove_button}
517 }
518 div { class: "blob-fields",
519 div { class: "blob-field-row blob-field-cid",
520 label { "CID:" }
521 input {
522 r#type: "text",
523 value: "{cid_input}",
524 oninput: handle_cid_change,
525 class: if cid_error().is_some() { "invalid" } else { "" },
526 }
527 if let Some(err) = cid_error() {
528 span { class: "field-error", " ❌ {err}" }
529 }
530 }
531 div { class: "blob-field-row",
532 label { "Size:" }
533 input {
534 r#type: "number",
535 value: "{size_input}",
536 oninput: handle_size_change,
537 class: if size_error().is_some() { "invalid" } else { "" },
538 }
539 if let Some(err) = size_error() {
540 span { class: "field-error", " ❌ {err}" }
541 }
542 }
543 div { class: "blob-field-row",
544 label { "MIME Type:" }
545 span { class: "readonly",
546 "{blob_data().map(|(_, _, mime)| mime).unwrap_or_default()}"
547 }
548 }
549 if let Some(url) = image_url {
550 img {
551 src: "{url}",
552 alt: "Blob preview",
553 class: "blob-image",
554 }
555 }
556 div { class: "blob-upload-section",
557 input {
558 r#type: "file",
559 accept: if is_image { "image/*" } else { "*/*" },
560 onchange: handle_file,
561 disabled: uploading(),
562 }
563 if uploading() {
564 span { class: "upload-status", "Uploading..." }
565 }
566 if let Some(err) = upload_error() {
567 div { class: "field-error", "❌ {err}" }
568 }
569 }
570 }
571 }
572 }
573}
574
575/// Decode image dimensions from data URL using browser Image API
576#[cfg(target_arch = "wasm32")]
577async fn decode_image_dimensions(data_url: &str) -> Option<(u32, u32)> {
578 use wasm_bindgen::JsCast;
579 use wasm_bindgen::prelude::*;
580 use wasm_bindgen_futures::JsFuture;
581
582 let window = web_sys::window()?;
583 let document = window.document()?;
584
585 let img = document.create_element("img").ok()?;
586 let img = img.dyn_into::<web_sys::HtmlImageElement>().ok()?;
587
588 img.set_src(data_url);
589
590 // Wait for image to load
591 let promise = js_sys::Promise::new(&mut |resolve, _reject| {
592 let onload = Closure::wrap(Box::new(move || {
593 resolve.call0(&JsValue::NULL).ok();
594 }) as Box<dyn FnMut()>);
595
596 img.set_onload(Some(onload.as_ref().unchecked_ref()));
597 onload.forget();
598 });
599
600 JsFuture::from(promise).await.ok()?;
601
602 Some((img.natural_width(), img.natural_height()))
603}
604
605/// Find and populate aspectRatio field for a blob
606#[allow(unused)]
607fn populate_aspect_ratio(
608 mut root: Signal<Data<'static>>,
609 blob_path: &str,
610 width: i64,
611 height: i64,
612) {
613 // Query for all aspectRatio fields and collect the path we want
614 let aspect_path_to_update = {
615 let data = root.read();
616 let query_result = data.query("...aspectRatio");
617
618 query_result.multiple().and_then(|matches| {
619 // Find aspectRatio that's a sibling of our blob
620 // e.g. blob at "embed.images[0].image" -> look for "embed.images[0].aspectRatio"
621 let blob_parent = blob_path.rsplit_once('.').map(|(parent, _)| parent);
622 matches.iter().find_map(|query_match| {
623 let aspect_parent = query_match.path.rsplit_once('.').map(|(parent, _)| parent);
624
625 // Check if they share the same parent
626 if blob_parent == aspect_parent {
627 Some(query_match.path.clone())
628 } else {
629 None
630 }
631 })
632 })
633 };
634
635 // Update the aspectRatio if we found a matching field
636 if let Some(aspect_path) = aspect_path_to_update {
637 let aspect_obj = atproto! {{
638 "width": width,
639 "height": height
640 }};
641
642 root.with_mut(|record_data| {
643 record_data.set_at_path(&aspect_path, aspect_obj);
644 });
645 }
646}
647
648/// Bytes field with hex/base64 auto-detection
649#[component]
650fn EditableBytesField(
651 root: Signal<Data<'static>>,
652 path: String,
653 #[props(default)] remove_button: Option<Element>,
654) -> Element {
655 let path_for_memo = path.clone();
656 let current_bytes = use_memo(move || {
657 root.read()
658 .get_at_path(&path_for_memo)
659 .and_then(|d| match d {
660 Data::Bytes(b) => Some(bytes_to_hex(b)),
661 _ => None,
662 })
663 });
664
665 let mut input_text = use_signal(|| String::new());
666 let mut parse_error = use_signal(|| None::<String>);
667 let mut detected_format = use_signal(|| None::<String>);
668
669 // Sync input when bytes change
670 use_effect(move || {
671 if let Some(hex) = current_bytes() {
672 input_text.set(hex);
673 }
674 });
675
676 let path_for_mutation = path.clone();
677 let handle_input = move |evt: Event<FormData>| {
678 let text = evt.value();
679 input_text.set(text.clone());
680
681 match parse_bytes_input(&text) {
682 Ok((bytes, format)) => {
683 parse_error.set(None);
684 detected_format.set(Some(format));
685 root.with_mut(|data| {
686 if let Some(target) = data.get_at_path_mut(&path_for_mutation) {
687 *target = Data::Bytes(bytes);
688 }
689 });
690 }
691 Err(e) => {
692 parse_error.set(Some(e));
693 detected_format.set(None);
694 }
695 }
696 };
697
698 let byte_count = current_bytes()
699 .map(|hex| hex.chars().filter(|c| c.is_ascii_hexdigit()).count() / 2)
700 .unwrap_or(0);
701 let size_label = if byte_count > 128 {
702 format_size(byte_count, humansize::BINARY)
703 } else {
704 format!("{} bytes", byte_count)
705 };
706
707 rsx! {
708 div { class: "record-field bytes-field",
709 div { class: "field-header",
710 PathLabel { path: path.clone() }
711 span { class: "string-type-tag", " [bytes: {size_label}]" }
712 if let Some(format) = detected_format() {
713 span { class: "bytes-format-tag", " ({format})" }
714 }
715 {remove_button}
716 }
717 textarea {
718 value: "{input_text}",
719 placeholder: "Paste hex (1a2b3c...) or base64 (YWJj...)",
720 oninput: handle_input,
721 class: if parse_error().is_some() { "invalid" } else { "" },
722 rows: "3",
723 }
724 if let Some(err) = parse_error() {
725 span { class: "field-error", " ❌ {err}" }
726 }
727 }
728 }
729}
730
731/// Parse bytes from hex or base64, auto-detecting format
732fn parse_bytes_input(text: &str) -> Result<(Bytes, String), String> {
733 let trimmed = text.trim();
734 if trimmed.is_empty() {
735 return Err("Input is empty".to_string());
736 }
737
738 // Remove common whitespace/separators
739 let cleaned: String = trimmed
740 .chars()
741 .filter(|c| !c.is_whitespace() && *c != ':' && *c != '-')
742 .collect();
743
744 // Try hex first (more restrictive)
745 if cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
746 parse_hex_bytes(&cleaned).map(|b| (b, "hex".to_string()))
747 } else {
748 // Try base64
749 parse_base64_bytes(&cleaned).map(|b| (b, "base64".to_string()))
750 }
751}
752
753/// Parse hex string to bytes
754fn parse_hex_bytes(hex: &str) -> Result<Bytes, String> {
755 if hex.len() % 2 != 0 {
756 return Err("Hex string must have even length".to_string());
757 }
758
759 let mut bytes = Vec::with_capacity(hex.len() / 2);
760 for chunk in hex.as_bytes().chunks(2) {
761 let hex_byte = std::str::from_utf8(chunk).map_err(|e| format!("Invalid UTF-8: {}", e))?;
762 let byte =
763 u8::from_str_radix(hex_byte, 16).map_err(|e| format!("Invalid hex digit: {}", e))?;
764 bytes.push(byte);
765 }
766
767 Ok(Bytes::from(bytes))
768}
769
770/// Parse base64 string to bytes
771fn parse_base64_bytes(b64: &str) -> Result<Bytes, String> {
772 use base64::Engine;
773 let engine = base64::engine::general_purpose::STANDARD;
774
775 engine
776 .decode(b64)
777 .map(Bytes::from)
778 .map_err(|e| format!("Invalid base64: {}", e))
779}
780
781/// Convert bytes to hex display string (with spacing every 4 chars)
782fn bytes_to_hex(bytes: &Bytes) -> String {
783 bytes
784 .iter()
785 .enumerate()
786 .map(|(i, b)| {
787 let hex = format!("{:02x}", b);
788 if i > 0 && i % 2 == 0 {
789 format!(" {}", hex)
790 } else {
791 hex
792 }
793 })
794 .collect()
795}
796
797/// CidLink field with validation
798#[component]
799fn EditableCidLinkField(
800 root: Signal<Data<'static>>,
801 path: String,
802 #[props(default)] remove_button: Option<Element>,
803) -> Element {
804 let path_for_memo = path.clone();
805 let current_cid = use_memo(move || {
806 root.read()
807 .get_at_path(&path_for_memo)
808 .map(|d| match d {
809 Data::CidLink(cid) => cid.to_string(),
810 _ => String::new(),
811 })
812 .unwrap_or_default()
813 });
814
815 let mut input_text = use_signal(|| String::new());
816 let mut parse_error = use_signal(|| None::<String>);
817
818 use_effect(move || {
819 input_text.set(current_cid());
820 });
821
822 let input_width = use_memo(move || {
823 let len = input_text().len();
824 format!("{}ch", len.max(60))
825 });
826
827 let path_for_mutation = path.clone();
828 let handle_input = move |evt: Event<FormData>| {
829 let text = evt.value();
830 input_text.set(text.clone());
831
832 match jacquard::types::cid::Cid::new_owned(text.as_bytes()) {
833 Ok(new_cid) => {
834 parse_error.set(None);
835 root.with_mut(|data| {
836 if let Some(target) = data.get_at_path_mut(&path_for_mutation) {
837 *target = Data::CidLink(new_cid);
838 }
839 });
840 }
841 Err(_) => {
842 parse_error.set(Some("Invalid CID format".to_string()));
843 }
844 }
845 };
846
847 rsx! {
848 div { class: "record-field cidlink-field",
849 div { class: "field-header",
850 PathLabel { path: path.clone() }
851 span { class: "string-type-tag", " [cid-link]" }
852 {remove_button}
853 }
854 input {
855 r#type: "text",
856 value: "{input_text}",
857 style: "width: {input_width}",
858 placeholder: "bafyrei...",
859 oninput: handle_input,
860 class: if parse_error().is_some() { "invalid" } else { "" },
861 }
862 if let Some(err) = parse_error() {
863 span { class: "field-error", " ❌ {err}" }
864 }
865 }
866 }
867}
868
869// ============================================================================
870// Field with Remove Button Wrapper
871// ============================================================================
872
873/// Wraps a field with an optional remove button in the header
874#[component]
875fn FieldWithRemove(
876 root: Signal<Data<'static>>,
877 path: String,
878 did: String,
879 is_removable: bool,
880 parent_path: String,
881 field_key: String,
882) -> Element {
883 let remove_button = if is_removable {
884 Some(rsx! {
885 button {
886 class: "field-remove-button",
887 onclick: move |_| {
888 let mut new_data = root.read().clone();
889 if let Some(Data::Object(obj)) = new_data.get_at_path_mut(parent_path.as_str()) {
890 obj.0.remove(field_key.as_str());
891 }
892 root.set(new_data);
893 },
894 "Remove"
895 }
896 })
897 } else {
898 None
899 };
900
901 rsx! {
902 EditableDataView {
903 root: root,
904 path: path.clone(),
905 did: did.clone(),
906 remove_button: remove_button,
907 }
908 }
909}
910
911// ============================================================================
912// Array Field Editor (enables recursion)
913// ============================================================================
914
915/// Array field - iterates items and renders child EditableDataView for each
916#[component]
917fn EditableArrayField(root: Signal<Data<'static>>, path: String, did: String) -> Element {
918 let path_for_memo = path.clone();
919 let array_len = use_memo(move || {
920 root.read()
921 .get_at_path(&path_for_memo)
922 .and_then(|d| d.as_array())
923 .map(|arr| arr.0.len())
924 .unwrap_or(0)
925 });
926
927 let path_for_add = path.clone();
928
929 rsx! {
930 div { class: "record-section array-section",
931 Accordion {
932 id: "edit-array-{path}",
933 collapsible: true,
934 AccordionItem {
935 default_open: true,
936 index: 0,
937 AccordionTrigger {
938 div { class: "record-section-header",
939 div { class: "section-label",
940 {
941 let parts: Vec<&str> = path.split('.').collect();
942 let final_part = parts.last().unwrap_or(&"");
943 rsx! { "{final_part}" }
944 }
945 }
946 span { class: "array-length", "[{array_len}]" }
947 }
948 }
949 AccordionContent {
950 div { class: "section-content",
951 for idx in 0..array_len() {
952 {
953 let item_path = format!("{}[{}]", path, idx);
954 let path_for_remove = path.clone();
955
956 rsx! {
957 div {
958 class: "array-item",
959 key: "{item_path}",
960
961 EditableDataView {
962 root: root,
963 path: item_path.clone(),
964 did: did.clone(),
965 remove_button: rsx! {
966 button {
967 class: "field-remove-button",
968 onclick: move |_| {
969 root.with_mut(|data| {
970 if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_remove) {
971 arr.0.remove(idx);
972 }
973 });
974 },
975 "Remove"
976 }
977 }
978 }
979 }
980 }
981 }
982 }
983 div {
984 class: "array-item",
985 div {
986 class: "add-field-widget",
987 button {
988 onclick: move |_| {
989 root.with_mut(|data| {
990 if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_add) {
991 let new_item = create_array_item_default(arr);
992 arr.0.push(new_item);
993 }
994 });
995 },
996 "+ Add Item"
997 }
998 }
999 }
1000 }
1001 }
1002 }
1003 }
1004 }
1005 }
1006}
1007
1008// ============================================================================
1009// Object Field Editor (enables recursion)
1010// ============================================================================
1011
1012/// Object field - iterates fields and renders child EditableDataView for each
1013#[component]
1014fn EditableObjectField(
1015 root: Signal<Data<'static>>,
1016 path: String,
1017 did: String,
1018 #[props(default)] remove_button: Option<Element>,
1019) -> Element {
1020 let path_for_memo = path.clone();
1021 let field_keys = use_memo(move || {
1022 root.read()
1023 .get_at_path(&path_for_memo)
1024 .and_then(|d| d.as_object())
1025 .map(|obj| obj.0.keys().cloned().collect::<Vec<_>>())
1026 .unwrap_or_default()
1027 });
1028
1029 let is_root = path.is_empty();
1030
1031 rsx! {
1032 if !is_root {
1033 div { class: "record-section object-section",
1034 Accordion {
1035 id: "edit-object-{path}",
1036 collapsible: true,
1037 AccordionItem {
1038 default_open: true,
1039 index: 0,
1040 AccordionTrigger {
1041 div { class: "record-section-header",
1042 div { class: "section-label",
1043 {
1044 let parts: Vec<&str> = path.split('.').collect();
1045 let final_part = parts.last().unwrap_or(&"");
1046 rsx! { "{final_part}" }
1047 }
1048 }
1049 {remove_button}
1050 }
1051 }
1052 AccordionContent {
1053 div { class: "section-content",
1054 for key in field_keys() {
1055 {
1056 let field_path = if path.is_empty() {
1057 key.to_string()
1058 } else {
1059 format!("{}.{}", path, key)
1060 };
1061 let is_type_field = key == "$type";
1062
1063 rsx! {
1064 FieldWithRemove {
1065 key: "{field_path}",
1066 root: root,
1067 path: field_path.clone(),
1068 did: did.clone(),
1069 is_removable: !is_type_field,
1070 parent_path: path.clone(),
1071 field_key: key.clone(),
1072 }
1073 }
1074 }
1075 }
1076
1077 AddFieldWidget { root: root, path: path.clone() }
1078 }
1079 }
1080 }
1081 }
1082 }
1083 } else {
1084 for key in field_keys() {
1085 {
1086 let field_path = key.to_string();
1087 let is_type_field = key == "$type";
1088
1089 rsx! {
1090 FieldWithRemove {
1091 key: "{field_path}",
1092 root: root,
1093 path: field_path.clone(),
1094 did: did.clone(),
1095 is_removable: !is_type_field,
1096 parent_path: path.clone(),
1097 field_key: key.clone(),
1098 }
1099 }
1100 }
1101 }
1102
1103 AddFieldWidget { root: root, path: path.clone() }
1104 }
1105 }
1106}
1107
1108/// Widget for adding new fields to objects
1109#[component]
1110fn AddFieldWidget(root: Signal<Data<'static>>, path: String) -> Element {
1111 let mut field_name = use_signal(|| String::new());
1112 let mut field_value = use_signal(|| String::new());
1113 let mut error = use_signal(|| None::<String>);
1114 let mut show_form = use_signal(|| false);
1115
1116 let path_for_enter = path.clone();
1117 let path_for_button = path.clone();
1118
1119 rsx! {
1120 div { class: "add-field-widget",
1121 if !show_form() {
1122 button {
1123 class: "add-button",
1124 onclick: move |_| show_form.set(true),
1125 "+ Add Field"
1126 }
1127 } else {
1128 div { class: "add-field-form",
1129 input {
1130 r#type: "text",
1131 placeholder: "Field name",
1132 value: "{field_name}",
1133 oninput: move |evt| field_name.set(evt.value()),
1134 }
1135 input {
1136 r#type: "text",
1137 placeholder: r#"Value: {{}}, [], true, 123, "text""#,
1138 value: "{field_value}",
1139 oninput: move |evt| field_value.set(evt.value()),
1140 onkeydown: move |evt| {
1141 use dioxus::prelude::keyboard_types::Key;
1142 if evt.key() == Key::Enter {
1143 let name = field_name();
1144 let value_text = field_value();
1145
1146 if name.is_empty() {
1147 error.set(Some("Field name required".to_string()));
1148 return;
1149 }
1150
1151 let new_value = match infer_data_from_text(&value_text) {
1152 Ok(data) => data,
1153 Err(e) => {
1154 error.set(Some(e));
1155 return;
1156 }
1157 };
1158
1159 let mut new_data = root.read().clone();
1160 if let Some(Data::Object(obj)) = new_data.get_at_path_mut(path_for_enter.as_str()) {
1161 obj.0.insert(name.into(), new_value);
1162 }
1163 root.set(new_data);
1164
1165 // Reset form
1166 field_name.set(String::new());
1167 field_value.set(String::new());
1168 show_form.set(false);
1169 error.set(None);
1170 }
1171 }
1172 }
1173 button {
1174 class: "add-field-widget-edit",
1175 onclick: move |_| {
1176 let name = field_name();
1177 let value_text = field_value();
1178
1179 if name.is_empty() {
1180 error.set(Some("Field name required".to_string()));
1181 return;
1182 }
1183
1184 let new_value = match infer_data_from_text(&value_text) {
1185 Ok(data) => data,
1186 Err(e) => {
1187 error.set(Some(e));
1188 return;
1189 }
1190 };
1191
1192 let mut new_data = root.read().clone();
1193 if let Some(Data::Object(obj)) = new_data.get_at_path_mut(path_for_button.as_str()) {
1194 obj.0.insert(name.into(), new_value);
1195 }
1196 root.set(new_data);
1197
1198 // Reset form
1199 field_name.set(String::new());
1200 field_value.set(String::new());
1201 show_form.set(false);
1202 error.set(None);
1203 },
1204 "Add"
1205 }
1206 button {
1207 class: "add-field-widget-edit",
1208 onclick: move |_| {
1209 show_form.set(false);
1210 field_name.set(String::new());
1211 field_value.set(String::new());
1212 error.set(None);
1213 },
1214 "Cancel"
1215 }
1216 if let Some(err) = error() {
1217 div { class: "field-error", "❌ {err}" }
1218 }
1219 }
1220 }
1221 }
1222 }
1223}
1224
1225#[component]
1226pub fn EditableRecordContent(
1227 record_value: Data<'static>,
1228 uri: ReadSignal<AtUri<'static>>,
1229 view_mode: Signal<ViewMode>,
1230 edit_mode: Signal<bool>,
1231 record_resource: Resource<Result<GetRecordOutput<'static>, AgentError>>,
1232 schema: ReadSignal<Option<LexiconDoc<'static>>>,
1233) -> Element {
1234 let mut edit_data = use_signal(use_reactive!(|record_value| record_value.clone()));
1235 let nsid = use_memo(move || edit_data().type_discriminator().map(|s| s.to_string()));
1236 let navigator = use_navigator();
1237 let fetcher = use_context::<Fetcher>();
1238
1239 // Validate edit_data whenever it changes and provide via context
1240 let mut validation_result = use_signal(|| None);
1241 use_effect(move || {
1242 let _ = schema(); // Track schema changes
1243 if let Some(nsid_str) = nsid() {
1244 let data = edit_data();
1245 let validator = jacquard_lexicon::validation::SchemaValidator::global();
1246 let result = validator.validate_by_nsid(&nsid_str, &data);
1247 validation_result.set(Some(result));
1248 }
1249 });
1250 use_context_provider(|| validation_result);
1251
1252 let update_fetcher = fetcher.clone();
1253 let create_fetcher = fetcher.clone();
1254 let replace_fetcher = fetcher.clone();
1255 let delete_fetcher = fetcher.clone();
1256
1257 rsx! {
1258 div {
1259 class: "tab-bar",
1260 button {
1261 class: if view_mode() == ViewMode::Pretty { "tab-button active" } else { "tab-button" },
1262 onclick: move |_| view_mode.set(ViewMode::Pretty),
1263 "View"
1264 }
1265 button {
1266 class: if view_mode() == ViewMode::Json { "tab-button active" } else { "tab-button" },
1267 onclick: move |_| view_mode.set(ViewMode::Json),
1268 "JSON"
1269 }
1270 button {
1271 class: if view_mode() == ViewMode::Schema { "tab-button active" } else { "tab-button" },
1272 onclick: move |_| view_mode.set(ViewMode::Schema),
1273 "Schema"
1274 }
1275 ActionButtons {
1276 on_update: move |_| {
1277 let fetcher = update_fetcher.clone();
1278 let uri = uri();
1279 let data = edit_data();
1280 spawn(async move {
1281 if let Some((did, _)) = fetcher.session_info().await {
1282 if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) {
1283 let collection = Nsid::new(collection_str.as_str()).ok();
1284 if let Some(collection) = collection {
1285 let request = PutRecord::new()
1286 .repo(AtIdentifier::Did(did))
1287 .collection(collection)
1288 .rkey(rkey.clone())
1289 .record(data.clone())
1290 .build();
1291
1292 match fetcher.send(request).await {
1293 Ok(output) => {
1294 if output.status() == StatusCode::OK.as_u16() {
1295 tracing::info!("Record updated successfully");
1296 edit_data.set(data.clone());
1297 edit_mode.set(false);
1298 } else {
1299 tracing::error!("Unexpected status code: {:?}", output.status());
1300 }
1301 }
1302 Err(e) => {
1303 tracing::error!("Failed to update record: {:?}", e);
1304 }
1305 }
1306 }
1307 }
1308 }
1309 });
1310 },
1311 on_save_new: move |_| {
1312 let fetcher = create_fetcher.clone();
1313 let data = edit_data();
1314 let nav = navigator.clone();
1315 spawn(async move {
1316 if let Some((did, _)) = fetcher.session_info().await {
1317 if let Some(collection_str) = data.type_discriminator() {
1318 let collection = Nsid::new(collection_str).ok();
1319 if let Some(collection) = collection {
1320 let request = CreateRecord::new()
1321 .repo(AtIdentifier::Did(did))
1322 .collection(collection)
1323 .record(data.clone())
1324 .build();
1325
1326 match fetcher.send(request).await {
1327 Ok(response) => {
1328 if let Ok(output) = response.into_output() {
1329 tracing::info!("Record created: {}", output.uri);
1330 let link = format!("{}/record/{}", crate::env::WEAVER_APP_HOST, output.uri);
1331 nav.push(link);
1332 }
1333 }
1334 Err(e) => {
1335 tracing::error!("Failed to create record: {:?}", e);
1336 }
1337 }
1338 }
1339 }
1340 }
1341 });
1342 },
1343 on_replace: move |_| {
1344 let fetcher = replace_fetcher.clone();
1345 let uri = uri();
1346 let data = edit_data();
1347 let nav = navigator.clone();
1348 spawn(async move {
1349 if let Some((did, _)) = fetcher.session_info().await {
1350 if let Some(new_collection_str) = data.type_discriminator() {
1351 let new_collection = Nsid::new(new_collection_str).ok();
1352 if let Some(new_collection) = new_collection {
1353 // Create new record first - if this fails, user keeps their old record
1354 // If delete fails after, user has duplicates (recoverable) rather than data loss
1355 let create_req = CreateRecord::new()
1356 .repo(AtIdentifier::Did(did.clone()))
1357 .collection(new_collection)
1358 .record(data.clone())
1359 .build();
1360
1361 match fetcher.send(create_req).await {
1362 Ok(response) => {
1363 if let Ok(create_output) = response.into_output() {
1364 // Delete old record after successful create
1365 if let (Some(old_collection_str), Some(old_rkey)) = (uri.collection(), uri.rkey()) {
1366 let old_collection = Nsid::new(old_collection_str.as_str()).ok();
1367 if let Some(old_collection) = old_collection {
1368 let delete_req = DeleteRecord::new()
1369 .repo(AtIdentifier::Did(did))
1370 .collection(old_collection)
1371 .rkey(old_rkey.clone())
1372 .build();
1373
1374 if let Err(e) = fetcher.send(delete_req).await {
1375 tracing::warn!("Created new record but failed to delete old: {:?}", e);
1376 }
1377 }
1378 }
1379
1380 tracing::info!("Record replaced: {}", create_output.uri);
1381 let link = format!("{}/record/{}", crate::env::WEAVER_APP_HOST, create_output.uri);
1382 nav.push(link);
1383 }
1384 }
1385 Err(e) => {
1386 tracing::error!("Failed to replace record: {:?}", e);
1387 }
1388 }
1389 }
1390 }
1391 }
1392 });
1393 },
1394 on_delete: move |_| {
1395 let fetcher = delete_fetcher.clone();
1396 let uri = uri();
1397 let nav = navigator.clone();
1398 spawn(async move {
1399 if let Some((did, _)) = fetcher.session_info().await {
1400 if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) {
1401 let collection = Nsid::new(collection_str.as_str()).ok();
1402 if let Some(collection) = collection {
1403 let request = DeleteRecord::new()
1404 .repo(AtIdentifier::Did(did))
1405 .collection(collection)
1406 .rkey(rkey.clone())
1407 .build();
1408
1409 match fetcher.send(request).await {
1410 Ok(_) => {
1411 tracing::info!("Record deleted");
1412 nav.push(Route::Home {});
1413 }
1414 Err(e) => {
1415 tracing::error!("Failed to delete record: {:?}", e);
1416 }
1417 }
1418 }
1419 }
1420 }
1421 });
1422 },
1423 on_cancel: move |_| {
1424 edit_data.set(record_value.clone());
1425 edit_mode.set(false);
1426 },
1427 }
1428 }
1429 div {
1430 class: "tab-content",
1431 match view_mode() {
1432 ViewMode::Pretty => rsx! {
1433 div { class: "pretty-record",
1434 EditableDataView {
1435 root: edit_data,
1436 path: String::new(),
1437 did: uri().authority().to_string(),
1438 }
1439 }
1440 },
1441 ViewMode::Json => rsx! {
1442 JsonEditor { data: edit_data, nsid, schema }
1443 },
1444 ViewMode::Schema => rsx! {
1445 SchemaView { schema }
1446 },
1447 }
1448 }
1449 }
1450}
1451
1452#[component]
1453pub fn JsonEditor(
1454 data: Signal<Data<'static>>,
1455 nsid: ReadSignal<Option<String>>,
1456 schema: ReadSignal<Option<LexiconDoc<'static>>>,
1457) -> Element {
1458 let mut json_text =
1459 use_signal(|| serde_json::to_string_pretty(&*data.read()).unwrap_or_default());
1460
1461 let height = use_memo(move || {
1462 let line_count = json_text().lines().count();
1463 let min_lines = 10;
1464 let lines = line_count.max(min_lines);
1465 // line-height is 1.5, font-size is 0.9rem (approx 14.4px), so each line is ~21.6px
1466 // Add padding (1rem top + 1rem bottom = 2rem = 32px)
1467 format!("{}px", lines * 22 + 32)
1468 });
1469
1470 let validation = use_resource(move || {
1471 let text = json_text();
1472 let nsid_val = nsid();
1473 let _ = schema(); // Track schema changes
1474
1475 async move {
1476 // Only validate if we have an NSID
1477 let nsid_str = nsid_val?;
1478
1479 // Parse JSON to Data
1480 let parsed = match serde_json::from_str::<Data>(&text) {
1481 Ok(val) => val.into_static(),
1482 Err(e) => {
1483 return Some((None, Some(e.to_string())));
1484 }
1485 };
1486
1487 // Use global validator (schema already registered)
1488 let validator = jacquard_lexicon::validation::SchemaValidator::global();
1489 let result = validator.validate_by_nsid(&nsid_str, &parsed);
1490
1491 Some((Some(result), None))
1492 }
1493 });
1494
1495 rsx! {
1496 div { class: "json-editor",
1497 textarea {
1498 class: "json-textarea",
1499 style: "height: {height};",
1500 value: "{json_text}",
1501 oninput: move |evt| {
1502 json_text.set(evt.value());
1503 // Update data signal on successful parse
1504 if let Ok(parsed) = serde_json::from_str::<Data>(&evt.value()) {
1505 data.set(parsed.into_static());
1506 }
1507 },
1508 }
1509
1510 ValidationPanel {
1511 validation: validation,
1512 }
1513 }
1514 }
1515}
1516
1517#[component]
1518pub fn ActionButtons(
1519 on_update: EventHandler<()>,
1520 on_save_new: EventHandler<()>,
1521 on_replace: EventHandler<()>,
1522 on_delete: EventHandler<()>,
1523 on_cancel: EventHandler<()>,
1524) -> Element {
1525 let mut show_save_dropdown = use_signal(|| false);
1526 let mut show_replace_warning = use_signal(|| false);
1527 let mut show_delete_confirm = use_signal(|| false);
1528
1529 rsx! {
1530 div { class: "action-buttons-group",
1531 button {
1532 class: "tab-button action-button",
1533 onclick: move |_| on_update.call(()),
1534 "Update"
1535 }
1536
1537 div { class: "dropdown-wrapper",
1538 button {
1539 class: "tab-button action-button",
1540 onclick: move |_| show_save_dropdown.toggle(),
1541 "Save as New ▼"
1542 }
1543 if show_save_dropdown() {
1544 div { class: "dropdown-menu",
1545 button {
1546 onclick: move |_| {
1547 show_save_dropdown.set(false);
1548 on_save_new.call(());
1549 },
1550 "Save as New"
1551 }
1552 button {
1553 onclick: move |_| {
1554 show_save_dropdown.set(false);
1555 show_replace_warning.set(true);
1556 },
1557 "Replace"
1558 }
1559 }
1560 }
1561 }
1562
1563 if show_replace_warning() {
1564 div { class: "inline-warning",
1565 "⚠️ This will delete the current record and create a new one with a different rkey. "
1566 button {
1567 onclick: move |_| {
1568 show_replace_warning.set(false);
1569 on_replace.call(());
1570 },
1571 "Yes"
1572 }
1573 button {
1574 onclick: move |_| show_replace_warning.set(false),
1575 "No"
1576 }
1577 }
1578 }
1579
1580 button {
1581 class: "tab-button action-button action-button-danger",
1582 onclick: move |_| show_delete_confirm.set(true),
1583 "Delete"
1584 }
1585
1586 DialogRoot {
1587 open: Some(show_delete_confirm()),
1588 on_open_change: move |open: bool| {
1589 show_delete_confirm.set(open);
1590 },
1591 DialogContent {
1592 DialogTitle { "Delete Record?" }
1593 DialogDescription {
1594 "This action cannot be undone."
1595 }
1596 div { class: "dialog-actions",
1597 button {
1598 onclick: move |_| {
1599 show_delete_confirm.set(false);
1600 on_delete.call(());
1601 },
1602 "Delete"
1603 }
1604 button {
1605 onclick: move |_| show_delete_confirm.set(false),
1606 "Cancel"
1607 }
1608 }
1609 }
1610 }
1611
1612 button {
1613 class: "tab-button action-button",
1614 onclick: move |_| on_cancel.call(()),
1615 "Cancel"
1616 }
1617 }
1618 }
1619}
1620
1621#[component]
1622pub fn ValidationPanel(
1623 validation: Resource<Option<(Option<ValidationResult>, Option<String>)>>,
1624) -> Element {
1625 rsx! {
1626 div { class: "validation-panel",
1627 if let Some(Some((result_opt, parse_error_opt))) = validation.read().as_ref() {
1628 if let Some(parse_err) = parse_error_opt {
1629 div { class: "parse-error",
1630 "❌ Invalid JSON: {parse_err}"
1631 }
1632 }
1633
1634 if let Some(result) = result_opt {
1635 // Structural validity
1636 if result.is_structurally_valid() {
1637 div { class: "validation-success", "✓ Structurally valid" }
1638 } else {
1639 div { class: "parse-error", "❌ Structurally invalid" }
1640 }
1641
1642 // Overall validity
1643 if result.is_valid() {
1644 div { class: "validation-success", "✓ Fully valid" }
1645 } else {
1646 div { class: "validation-warning", "⚠ Has errors" }
1647 }
1648
1649 // Show errors if any
1650 if !result.is_valid() {
1651 div { class: "validation-errors",
1652 h4 { "Validation Errors:" }
1653 for error in result.all_errors() {
1654 div { class: "error", "{error}" }
1655 }
1656 }
1657 }
1658 }
1659 } else {
1660 div { "Validating..." }
1661 }
1662 }
1663 }
1664}