at main 1664 lines 64 kB view raw
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}