prett editor WIP

Orual 0b325539 3e94583b

+1071 -254
+131
crates/weaver-app/assets/styling/record-view.css
··· 461 461 padding-left: 0.5rem; 462 462 margin: 0.25rem 0; 463 463 } 464 + 465 + /* Pretty Editor Input Fields */ 466 + .record-field input[type="text"], 467 + .record-field input[type="number"] { 468 + font-family: var(--font-mono); 469 + font-size: 0.9rem; 470 + color: var(--color-text); 471 + background: transparent; 472 + border: none; 473 + border-bottom: 1px solid transparent; 474 + padding: 0.2rem 0 0.1rem 0; 475 + margin-top: 0.1rem; 476 + outline: none; 477 + width: 100%; 478 + transition: border-color 0.2s; 479 + } 480 + 481 + .record-field input[type="text"]:focus, 482 + .record-field input[type="number"]:focus { 483 + border-bottom-color: var(--color-primary); 484 + } 485 + 486 + .record-field input[type="text"].invalid, 487 + .record-field input[type="number"].invalid { 488 + border-bottom-color: var(--color-error, #ff6b6b); 489 + } 490 + 491 + .record-field input[type="checkbox"] { 492 + width: 1.1rem; 493 + height: 1.1rem; 494 + margin-top: 0.3rem; 495 + margin-bottom: 0.3rem; 496 + cursor: pointer; 497 + accent-color: var(--color-primary); 498 + } 499 + 500 + .field-error { 501 + font-family: var(--font-mono); 502 + font-size: 0.75rem; 503 + color: var(--color-error, #ff6b6b); 504 + padding-top: 0.2rem; 505 + padding-bottom: 0.3rem; 506 + } 507 + 508 + .add-field-widget { 509 + display: flex; 510 + gap: 0.5rem; 511 + align-items: center; 512 + padding: 1rem 0 0rem 0rem; 513 + margin-bottom: 1rem; 514 + border-left: 1px solid var(--color-border); 515 + } 516 + 517 + .add-field-widget input[type="text"] { 518 + font-family: var(--font-mono); 519 + font-size: 0.85rem; 520 + color: var(--color-text); 521 + background: var(--color-background-alt, rgba(0, 0, 0, 0.2)); 522 + border: 1px solid var(--color-border); 523 + padding: 0.3rem 0.5rem; 524 + margin-left: -1px; 525 + outline: none; 526 + } 527 + 528 + .add-field-widget input[type="text"]:focus { 529 + border-color: var(--color-primary); 530 + } 531 + 532 + .add-field-widget button { 533 + font-family: var(--font-mono); 534 + font-size: 0.75rem; 535 + text-transform: uppercase; 536 + letter-spacing: 0.05em; 537 + padding: 0.3rem 0.75rem; 538 + margin-left: -1px; 539 + background: transparent; 540 + border: 1px solid var(--color-border); 541 + color: var(--color-primary); 542 + cursor: pointer; 543 + transition: all 0.2s; 544 + } 545 + 546 + .add-field-form button { 547 + font-family: var(--font-mono); 548 + font-size: 0.75rem; 549 + text-transform: uppercase; 550 + letter-spacing: 0.05em; 551 + padding: 0.36rem 0.75rem; 552 + margin-left: -1px; 553 + background: transparent; 554 + border: 1px solid var(--color-primary); 555 + color: var(--color-primary); 556 + cursor: pointer; 557 + transition: all 0.2s; 558 + } 559 + 560 + .add-field-form button:hover { 561 + background-color: var(--color-primary); 562 + color: var(--color-surface); 563 + } 564 + 565 + .add-field-widget button:hover { 566 + border: 1px solid var(--color-primary); 567 + } 568 + 569 + .add-field-widget button:disabled { 570 + opacity: 0.4; 571 + cursor: not-allowed; 572 + } 573 + 574 + .field-header { 575 + display: flex; 576 + align-items: baseline; 577 + gap: 0.5rem; 578 + } 579 + 580 + .field-remove-button { 581 + font-family: var(--font-mono); 582 + font-size: 0.7rem; 583 + color: var(--color-subtle); 584 + background: transparent; 585 + border: none; 586 + padding: 0.2rem 0.4rem; 587 + cursor: pointer; 588 + margin-left: auto; 589 + transition: color 0.2s; 590 + } 591 + 592 + .field-remove-button:hover { 593 + color: var(--color-error, #ff6b6b); 594 + }
+940 -254
crates/weaver-app/src/views/record.rs
··· 5 5 use dioxus::prelude::*; 6 6 use dioxus_logger::tracing::*; 7 7 use humansize::format_size; 8 + use jacquard::api::com_atproto::repo::get_record::GetRecordOutput; 9 + use jacquard::client::AgentError; 8 10 use jacquard::prelude::*; 9 11 use jacquard::smol_str::ToSmolStr; 10 12 use jacquard::{ ··· 12 14 common::{Data, IntoStatic}, 13 15 identity::lexicon_resolver::LexiconSchemaResolver, 14 16 smol_str::SmolStr, 15 - types::{aturi::AtUri, ident::AtIdentifier, string::Nsid}, 17 + types::{aturi::AtUri, cid::Cid, ident::AtIdentifier, string::Nsid}, 16 18 }; 17 19 use weaver_api::com_atproto::repo::{ 18 20 create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord, ··· 45 47 let navigator = use_navigator(); 46 48 47 49 let client = fetcher.get_client(); 48 - let record = use_resource(move || { 50 + let record_resource = use_resource(move || { 49 51 let client = client.clone(); 50 52 async move { client.fetch_record_slingshot(&uri()).await } 51 53 }); ··· 67 69 } 68 70 } 69 71 }); 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(); 72 + if let Some(Ok(record)) = &*record_resource.read_unchecked() { 73 + let mut record_value = use_signal(|| record.value.clone().into_static()); 74 + let json = 75 + use_memo(move || serde_json::to_string_pretty(&record_value()).unwrap_or_default()); 76 + 75 77 rsx! { 76 - document::Stylesheet { href: asset!("/assets/styling/record-view.css") } 77 - div { 78 - class: "record-view-container", 79 - div { 80 - class: "record-header", 81 - h1 { "Record" } 78 + RecordViewLayout { 79 + uri: uri().clone(), 80 + cid: record.cid.clone(), 81 + if edit_mode() { 82 + EditableRecordContent { 83 + record_value: record_value, 84 + uri: uri, 85 + view_mode: view_mode, 86 + edit_mode: edit_mode, 87 + record_resource: record_resource, 88 + } 89 + } else { 82 90 div { 83 - class: "record-metadata", 84 - div { class: "metadata-row", 85 - span { class: "metadata-label", "URI" } 86 - span { class: "metadata-value", 87 - HighlightedUri { uri: uri().clone() } 88 - } 91 + class: "tab-bar", 92 + button { 93 + class: if view_mode() == ViewMode::Pretty { "tab-button active" } else { "tab-button" }, 94 + onclick: move |_| view_mode.set(ViewMode::Pretty), 95 + "View" 89 96 } 90 - if let Some(cid) = &record.cid { 91 - div { class: "metadata-row", 92 - span { class: "metadata-label", "CID" } 93 - code { class: "metadata-value", "{cid}" } 94 - } 97 + button { 98 + class: if view_mode() == ViewMode::Json { "tab-button active" } else { "tab-button" }, 99 + onclick: move |_| view_mode.set(ViewMode::Json), 100 + "JSON" 95 101 } 96 - } 97 - } 98 - div { 99 - class: "tab-bar", 100 - button { 101 - class: if view_mode() == ViewMode::Pretty { "tab-button active" } else { "tab-button" }, 102 - onclick: move |_| view_mode.set(ViewMode::Pretty), 103 - "View" 104 - } 105 - button { 106 - class: if view_mode() == ViewMode::Json { "tab-button active" } else { "tab-button" }, 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 - } 102 + if is_owner() { 103 + button { 104 + class: "tab-button edit-button", 105 + onclick: move |_| edit_mode.set(true), 106 + "Edit" 122 107 } 123 108 } 124 109 } 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 - }, 110 + div { 111 + class: "tab-content", 112 + match view_mode() { 113 + ViewMode::Pretty => rsx! { 114 + PrettyRecordView { record: record_value(), uri: uri().clone() } 115 + }, 116 + ViewMode::Json => rsx! { 117 + CodeView { 118 + code: use_signal(|| json()), 119 + lang: Some("json".to_string()), 276 120 } 277 - } 121 + }, 278 122 } 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 123 } 303 124 } 304 125 } ··· 444 265 span { class: "field-value", "{cid}" } 445 266 } 446 267 }, 447 - Data::Array(arr) => rsx! { 448 - div { class: "record-section", 449 - div { class: "section-label", "{path}" span { class: "array-len", "[{arr.len()}] " } } 268 + Data::Array(arr) => { 269 + let label = path.split('.').last().unwrap_or(&path); 270 + rsx! { 271 + div { class: "record-section", 272 + div { class: "section-label", "{label}" span { class: "array-len", "[{arr.len()}] " } } 273 + 274 + div { class: "section-content", 275 + for (idx, item) in arr.iter().enumerate() { 276 + { 277 + let item_path = format!("{}[{}]", path, idx); 278 + let is_object = matches!(item, Data::Object(_)); 450 279 451 - div { class: "section-content", 452 - for (idx, item) in arr.iter().enumerate() { 453 - DataView { 454 - data: item.clone(), 455 - path: format!("{}[{}]", path, idx), 456 - did: did.clone() 280 + if is_object { 281 + rsx! { 282 + div { class: "record-section", 283 + div { class: "section-label", "{item_path}" } 284 + div { class: "section-content", 285 + DataView { 286 + data: item.clone(), 287 + path: item_path.clone(), 288 + did: did.clone() 289 + } 290 + } 291 + } 292 + } 293 + } else { 294 + rsx! { 295 + DataView { 296 + data: item.clone(), 297 + path: item_path, 298 + did: did.clone() 299 + } 300 + } 301 + } 302 + } 457 303 } 458 304 } 459 305 } 460 306 } 461 - }, 462 - Data::Object(obj) => rsx! { 463 - for (key, value) in obj.iter() { 464 - { 465 - let new_path = if path.is_empty() { 466 - key.to_string() 467 - } else { 468 - format!("{}.{}", path, key) 469 - }; 470 - let did_clone = did.clone(); 307 + } 308 + Data::Object(obj) => { 309 + let is_root = path.is_empty(); 310 + let is_array_item = path.contains('['); 471 311 472 - match value { 473 - Data::Object(_) | Data::Array(_) => rsx! { 474 - div { class: "record-section", 475 - div { class: "section-label", "{key}" } 476 - div { class: "section-content", 477 - DataView { data: value.clone(), path: new_path, did: did_clone } 312 + if is_root || is_array_item { 313 + // Root object or array item: just render children (array items already wrapped) 314 + rsx! { 315 + for (key, value) in obj.iter() { 316 + { 317 + let new_path = if is_root { 318 + key.to_string() 319 + } else { 320 + format!("{}.{}", path, key) 321 + }; 322 + let did_clone = did.clone(); 323 + rsx! { 324 + DataView { data: value.clone(), path: new_path, did: did_clone } 325 + } 326 + } 327 + } 328 + } 329 + } else { 330 + // Nested object (not array item): wrap in section 331 + let label = path.split('.').last().unwrap_or(&path); 332 + rsx! { 333 + div { class: "record-section", 334 + div { class: "section-label", "{label}" } 335 + div { class: "section-content", 336 + for (key, value) in obj.iter() { 337 + { 338 + let new_path = format!("{}.{}", path, key); 339 + let did_clone = did.clone(); 340 + rsx! { 341 + DataView { data: value.clone(), path: new_path, did: did_clone } 342 + } 478 343 } 479 344 } 480 - }, 481 - _ => rsx! { 482 - DataView { data: value.clone(), path: new_path, did: did_clone } 483 345 } 484 346 } 485 347 } 486 348 } 487 - }, 349 + } 488 350 Data::Blob(blob) => { 489 351 let is_image = blob.mime_type.starts_with("image/"); 490 352 let format = blob.mime_type.strip_prefix("image/").unwrap_or("jpeg"); ··· 872 734 } 873 735 } 874 736 737 + // ============================================================================ 738 + // Pretty Editor: Helper Functions 739 + // ============================================================================ 740 + 741 + /// Infer Data type from text input 742 + fn infer_data_from_text(text: &str) -> Result<Data<'static>, String> { 743 + let trimmed = text.trim(); 744 + 745 + if trimmed == "true" || trimmed == "false" { 746 + Ok(Data::Boolean(trimmed == "true")) 747 + } else if trimmed == "{}" { 748 + use jacquard::types::value::Object; 749 + use std::collections::BTreeMap; 750 + Ok(Data::Object(Object(BTreeMap::new()))) 751 + } else if trimmed == "[]" { 752 + use jacquard::types::value::Array; 753 + Ok(Data::Array(Array(Vec::new()))) 754 + } else if trimmed == "null" { 755 + Ok(Data::Null) 756 + } else if let Ok(num) = trimmed.parse::<i64>() { 757 + Ok(Data::Integer(num)) 758 + } else { 759 + // Smart string parsing 760 + use jacquard::types::value::parsing; 761 + Ok(Data::String(parsing::parse_string(trimmed).into_static())) 762 + } 763 + } 764 + 765 + /// Parse text as specific AtprotoStr type, preserving type information 766 + fn try_parse_as_type( 767 + text: &str, 768 + string_type: jacquard::types::LexiconStringType, 769 + ) -> Result<jacquard::types::string::AtprotoStr<'static>, String> { 770 + use jacquard::types::LexiconStringType; 771 + use jacquard::types::string::*; 772 + use std::str::FromStr; 773 + 774 + match string_type { 775 + LexiconStringType::Datetime => Datetime::from_str(text) 776 + .map(AtprotoStr::Datetime) 777 + .map_err(|e| format!("Invalid datetime: {}", e)), 778 + LexiconStringType::Did => Did::new(text) 779 + .map(|v| AtprotoStr::Did(v.into_static())) 780 + .map_err(|e| format!("Invalid DID: {}", e)), 781 + LexiconStringType::Handle => Handle::new(text) 782 + .map(|v| AtprotoStr::Handle(v.into_static())) 783 + .map_err(|e| format!("Invalid handle: {}", e)), 784 + LexiconStringType::AtUri => AtUri::new(text) 785 + .map(|v| AtprotoStr::AtUri(v.into_static())) 786 + .map_err(|e| format!("Invalid AT-URI: {}", e)), 787 + LexiconStringType::AtIdentifier => AtIdentifier::new(text) 788 + .map(|v| AtprotoStr::AtIdentifier(v.into_static())) 789 + .map_err(|e| format!("Invalid identifier: {}", e)), 790 + LexiconStringType::Nsid => Nsid::new(text) 791 + .map(|v| AtprotoStr::Nsid(v.into_static())) 792 + .map_err(|e| format!("Invalid NSID: {}", e)), 793 + LexiconStringType::Tid => Tid::new(text) 794 + .map(|v| AtprotoStr::Tid(v.into_static())) 795 + .map_err(|e| format!("Invalid TID: {}", e)), 796 + LexiconStringType::RecordKey => Rkey::new(text) 797 + .map(|rk| AtprotoStr::RecordKey(RecordKey::from(rk))) 798 + .map_err(|e| format!("Invalid record key: {}", e)), 799 + LexiconStringType::Cid => Cid::new(text.as_bytes()) 800 + .map(|v| AtprotoStr::Cid(v.into_static())) 801 + .map_err(|_| "Invalid CID".to_string()), 802 + LexiconStringType::Language => Language::new(text) 803 + .map(AtprotoStr::Language) 804 + .map_err(|e| format!("Invalid language: {}", e)), 805 + LexiconStringType::Uri(_) => Uri::new(text) 806 + .map(|u| AtprotoStr::Uri(u.into_static())) 807 + .map_err(|e| format!("Invalid URI: {}", e)), 808 + LexiconStringType::String => { 809 + // Plain strings: use smart inference 810 + use jacquard::types::value::parsing; 811 + Ok(parsing::parse_string(text).into_static()) 812 + } 813 + } 814 + } 815 + 816 + // ============================================================================ 817 + // Pretty Editor: Component Hierarchy 818 + // ============================================================================ 819 + 820 + /// Main dispatcher - routes to specific field editors based on Data type 821 + #[component] 822 + fn EditableDataView( 823 + root: Signal<Data<'static>>, 824 + path: String, 825 + did: String, 826 + #[props(default)] remove_button: Option<Element>, 827 + ) -> Element { 828 + let path_for_memo = path.clone(); 829 + let root_read = root.read(); 830 + 831 + match root_read 832 + .get_at_path(&path_for_memo) 833 + .map(|d| d.clone().into_static()) 834 + { 835 + Some(Data::Object(_)) => rsx! { EditableObjectField { root, path: path.clone(), did } }, 836 + Some(Data::String(_)) => { 837 + rsx! { EditableStringField { root, path: path.clone(), remove_button } } 838 + } 839 + Some(Data::Integer(_)) => { 840 + rsx! { EditableIntegerField { root, path: path.clone(), remove_button } } 841 + } 842 + Some(Data::Boolean(_)) => { 843 + rsx! { EditableBooleanField { root, path: path.clone(), remove_button } } 844 + } 845 + Some(Data::Null) => rsx! { EditableNullField { root, path: path.clone(), remove_button } }, 846 + 847 + // Not yet implemented - fall back to read-only DataView 848 + Some(data) => { 849 + rsx! { DataView { data: data.clone(), path: path.clone(), did } } 850 + } 851 + 852 + None => rsx! { div { class: "field-error", "❌ Path not found: {path}" } }, 853 + } 854 + } 855 + 856 + // ============================================================================ 857 + // Primitive Field Editors 858 + // ============================================================================ 859 + 860 + /// String field with type preservation 861 + #[component] 862 + fn EditableStringField( 863 + root: Signal<Data<'static>>, 864 + path: String, 865 + #[props(default)] remove_button: Option<Element>, 866 + ) -> Element { 867 + use jacquard::types::LexiconStringType; 868 + 869 + let path_for_text = path.clone(); 870 + let path_for_type = path.clone(); 871 + 872 + // Get current string value 873 + let current_text = use_memo(move || { 874 + root.read() 875 + .get_at_path(&path_for_text) 876 + .and_then(|d| d.as_str()) 877 + .map(|s| s.to_string()) 878 + .unwrap_or_default() 879 + }); 880 + 881 + // Get string type (Copy, cheap to store) 882 + let string_type = use_memo(move || { 883 + root.read() 884 + .get_at_path(&path_for_type) 885 + .and_then(|d| match d { 886 + Data::String(s) => Some(s.string_type()), 887 + _ => None, 888 + }) 889 + .unwrap_or(LexiconStringType::String) 890 + }); 891 + 892 + // Local state for invalid input 893 + let mut input_text = use_signal(|| current_text()); 894 + let mut parse_error = use_signal(|| None::<String>); 895 + 896 + // Sync input when current changes 897 + use_effect(move || { 898 + input_text.set(current_text()); 899 + }); 900 + 901 + let path_for_mutation = path.clone(); 902 + let handle_input = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| { 903 + let new_text = evt.value(); 904 + input_text.set(new_text.clone()); 905 + 906 + match try_parse_as_type(&new_text, string_type()) { 907 + Ok(new_atproto_str) => { 908 + parse_error.set(None); 909 + let mut new_data = root.read().clone(); 910 + new_data.set_at_path(&path_for_mutation, Data::String(new_atproto_str)); 911 + root.set(new_data); 912 + } 913 + Err(e) => { 914 + parse_error.set(Some(e)); 915 + } 916 + } 917 + }; 918 + 919 + let type_label = format!("{:?}", string_type()).to_lowercase(); 920 + 921 + rsx! { 922 + div { class: "record-field", 923 + div { class: "field-header", 924 + PathLabel { path: path.clone() } 925 + if type_label != "string" { 926 + span { class: "string-type-tag", " [{type_label}]" } 927 + } 928 + {remove_button} 929 + } 930 + input { 931 + r#type: "text", 932 + value: "{input_text}", 933 + oninput: handle_input, 934 + class: if parse_error().is_some() { "invalid" } else { "" }, 935 + } 936 + if let Some(err) = parse_error() { 937 + span { class: "field-error", " ❌ {err}" } 938 + } 939 + } 940 + } 941 + } 942 + 943 + /// Integer field with validation 944 + #[component] 945 + fn EditableIntegerField( 946 + root: Signal<Data<'static>>, 947 + path: String, 948 + #[props(default)] remove_button: Option<Element>, 949 + ) -> Element { 950 + let path_for_memo = path.clone(); 951 + let current_value = use_memo(move || { 952 + root.read() 953 + .get_at_path(&path_for_memo) 954 + .and_then(|d| d.as_integer()) 955 + .unwrap_or(0) 956 + }); 957 + 958 + let mut input_text = use_signal(|| current_value().to_string()); 959 + let mut parse_error = use_signal(|| None::<String>); 960 + 961 + use_effect(move || { 962 + input_text.set(current_value().to_string()); 963 + }); 964 + 965 + let path_for_mutation = path.clone(); 966 + 967 + rsx! { 968 + div { class: "record-field", 969 + div { class: "field-header", 970 + PathLabel { path: path.clone() } 971 + {remove_button} 972 + } 973 + input { 974 + r#type: "number", 975 + value: "{input_text}", 976 + oninput: move |evt| { 977 + let text = evt.value(); 978 + input_text.set(text.clone()); 979 + 980 + match text.parse::<i64>() { 981 + Ok(num) => { 982 + parse_error.set(None); 983 + let mut data_edit = root.write_unchecked(); 984 + data_edit.set_at_path(&path_for_mutation, Data::Integer(num)); 985 + } 986 + Err(_) => { 987 + parse_error.set(Some("Must be a valid integer".to_string())); 988 + } 989 + } 990 + } 991 + } 992 + if let Some(err) = parse_error() { 993 + span { class: "field-error", " ❌ {err}" } 994 + } 995 + } 996 + } 997 + } 998 + 999 + /// Boolean field (checkbox) 1000 + #[component] 1001 + fn EditableBooleanField( 1002 + root: Signal<Data<'static>>, 1003 + path: String, 1004 + #[props(default)] remove_button: Option<Element>, 1005 + ) -> Element { 1006 + let path_for_memo = path.clone(); 1007 + let current_value = use_memo(move || { 1008 + root.read() 1009 + .get_at_path(&path_for_memo) 1010 + .and_then(|d| d.as_boolean()) 1011 + .unwrap_or(false) 1012 + }); 1013 + 1014 + let path_for_mutation = path.clone(); 1015 + rsx! { 1016 + div { class: "record-field", 1017 + div { class: "field-header", 1018 + PathLabel { path: path.clone() } 1019 + {remove_button} 1020 + } 1021 + input { 1022 + r#type: "checkbox", 1023 + checked: current_value(), 1024 + onchange: move |evt| { 1025 + root.with_mut(|data| { 1026 + if let Some(target) = data.get_at_path_mut(path_for_mutation.as_str()) { 1027 + *target = Data::Boolean(evt.checked()); 1028 + } 1029 + }); 1030 + } 1031 + } 1032 + } 1033 + } 1034 + } 1035 + 1036 + /// Null field with type inference 1037 + #[component] 1038 + fn EditableNullField( 1039 + root: Signal<Data<'static>>, 1040 + path: String, 1041 + #[props(default)] remove_button: Option<Element>, 1042 + ) -> Element { 1043 + let mut input_text = use_signal(|| String::new()); 1044 + let mut parse_error = use_signal(|| None::<String>); 1045 + 1046 + let path_for_mutation = path.clone(); 1047 + rsx! { 1048 + div { class: "record-field", 1049 + div { class: "field-header", 1050 + PathLabel { path: path.clone() } 1051 + span { class: "field-value muted", "null" } 1052 + {remove_button} 1053 + } 1054 + input { 1055 + r#type: "text", 1056 + placeholder: "Enter value (or {{}}, [], true, 123)...", 1057 + value: "{input_text}", 1058 + oninput: move |evt| { 1059 + input_text.set(evt.value()); 1060 + }, 1061 + onkeydown: move |evt| { 1062 + use dioxus::prelude::keyboard_types::Key; 1063 + if evt.key() == Key::Enter { 1064 + let text = input_text(); 1065 + match infer_data_from_text(&text) { 1066 + Ok(new_value) => { 1067 + root.with_mut(|data| { 1068 + if let Some(target) = data.get_at_path_mut(path_for_mutation.as_str()) { 1069 + *target = new_value; 1070 + } 1071 + }); 1072 + input_text.set(String::new()); 1073 + parse_error.set(None); 1074 + } 1075 + Err(e) => { 1076 + parse_error.set(Some(e)); 1077 + } 1078 + } 1079 + } 1080 + } 1081 + } 1082 + if let Some(err) = parse_error() { 1083 + span { class: "field-error", " ❌ {err}" } 1084 + } 1085 + } 1086 + } 1087 + } 1088 + 1089 + // ============================================================================ 1090 + // Field with Remove Button Wrapper 1091 + // ============================================================================ 1092 + 1093 + /// Wraps a field with an optional remove button in the header 1094 + #[component] 1095 + fn FieldWithRemove( 1096 + root: Signal<Data<'static>>, 1097 + path: String, 1098 + did: String, 1099 + is_removable: bool, 1100 + parent_path: String, 1101 + field_key: String, 1102 + ) -> Element { 1103 + let remove_button = if is_removable { 1104 + Some(rsx! { 1105 + button { 1106 + class: "field-remove-button", 1107 + onclick: move |_| { 1108 + let mut new_data = root.read().clone(); 1109 + if let Some(Data::Object(obj)) = new_data.get_at_path_mut(parent_path.as_str()) { 1110 + obj.0.remove(field_key.as_str()); 1111 + } 1112 + root.set(new_data); 1113 + }, 1114 + "Remove" 1115 + } 1116 + }) 1117 + } else { 1118 + None 1119 + }; 1120 + 1121 + rsx! { 1122 + EditableDataView { 1123 + root: root, 1124 + path: path.clone(), 1125 + did: did.clone(), 1126 + remove_button: remove_button, 1127 + } 1128 + } 1129 + } 1130 + 1131 + // ============================================================================ 1132 + // Object Field Editor (enables recursion) 1133 + // ============================================================================ 1134 + 1135 + /// Object field - iterates fields and renders child EditableDataView for each 1136 + #[component] 1137 + fn EditableObjectField(root: Signal<Data<'static>>, path: String, did: String) -> Element { 1138 + let path_for_memo = path.clone(); 1139 + let field_keys = use_memo(move || { 1140 + root.read() 1141 + .get_at_path(&path_for_memo) 1142 + .and_then(|d| d.as_object()) 1143 + .map(|obj| obj.0.keys().cloned().collect::<Vec<_>>()) 1144 + .unwrap_or_default() 1145 + }); 1146 + 1147 + let is_root = path.is_empty(); 1148 + 1149 + rsx! { 1150 + if !is_root { 1151 + div { class: "record-section object-section", 1152 + div { class: "section-label", 1153 + { 1154 + let parts: Vec<&str> = path.split('.').collect(); 1155 + let final_part = parts.last().unwrap_or(&""); 1156 + rsx! { "{final_part}" } 1157 + } 1158 + } 1159 + div { class: "section-content", 1160 + for key in field_keys() { 1161 + { 1162 + let field_path = if path.is_empty() { 1163 + key.to_string() 1164 + } else { 1165 + format!("{}.{}", path, key) 1166 + }; 1167 + let is_type_field = key == "$type"; 1168 + 1169 + rsx! { 1170 + FieldWithRemove { 1171 + key: "{field_path}", 1172 + root: root, 1173 + path: field_path.clone(), 1174 + did: did.clone(), 1175 + is_removable: !is_type_field, 1176 + parent_path: path.clone(), 1177 + field_key: key.clone(), 1178 + } 1179 + } 1180 + } 1181 + } 1182 + 1183 + AddFieldWidget { root: root, path: path.clone() } 1184 + } 1185 + } 1186 + } else { 1187 + for key in field_keys() { 1188 + { 1189 + let field_path = key.to_string(); 1190 + let is_type_field = key == "$type"; 1191 + 1192 + rsx! { 1193 + FieldWithRemove { 1194 + key: "{field_path}", 1195 + root: root, 1196 + path: field_path.clone(), 1197 + did: did.clone(), 1198 + is_removable: !is_type_field, 1199 + parent_path: path.clone(), 1200 + field_key: key.clone(), 1201 + } 1202 + } 1203 + } 1204 + } 1205 + 1206 + AddFieldWidget { root: root, path: path.clone() } 1207 + } 1208 + } 1209 + } 1210 + 1211 + /// Widget for adding new fields to objects 1212 + #[component] 1213 + fn AddFieldWidget(root: Signal<Data<'static>>, path: String) -> Element { 1214 + let mut field_name = use_signal(|| String::new()); 1215 + let mut field_value = use_signal(|| String::new()); 1216 + let mut error = use_signal(|| None::<String>); 1217 + let mut show_form = use_signal(|| false); 1218 + 1219 + let path_for_enter = path.clone(); 1220 + let path_for_button = path.clone(); 1221 + 1222 + rsx! { 1223 + div { class: "add-field-widget", 1224 + if !show_form() { 1225 + button { 1226 + class: "add-button", 1227 + onclick: move |_| show_form.set(true), 1228 + "+ Add Field" 1229 + } 1230 + } else { 1231 + div { class: "add-field-form", 1232 + input { 1233 + r#type: "text", 1234 + placeholder: "Field name", 1235 + value: "{field_name}", 1236 + oninput: move |evt| field_name.set(evt.value()), 1237 + } 1238 + input { 1239 + r#type: "text", 1240 + placeholder: r#"Value: {{}}, [], true, 123, "text""#, 1241 + value: "{field_value}", 1242 + oninput: move |evt| field_value.set(evt.value()), 1243 + onkeydown: move |evt| { 1244 + use dioxus::prelude::keyboard_types::Key; 1245 + if evt.key() == Key::Enter { 1246 + let name = field_name(); 1247 + let value_text = field_value(); 1248 + 1249 + if name.is_empty() { 1250 + error.set(Some("Field name required".to_string())); 1251 + return; 1252 + } 1253 + 1254 + let new_value = match infer_data_from_text(&value_text) { 1255 + Ok(data) => data, 1256 + Err(e) => { 1257 + error.set(Some(e)); 1258 + return; 1259 + } 1260 + }; 1261 + 1262 + let mut new_data = root.read().clone(); 1263 + if let Some(Data::Object(obj)) = new_data.get_at_path_mut(path_for_enter.as_str()) { 1264 + obj.0.insert(name.into(), new_value); 1265 + } 1266 + root.set(new_data); 1267 + 1268 + // Reset form 1269 + field_name.set(String::new()); 1270 + field_value.set(String::new()); 1271 + show_form.set(false); 1272 + error.set(None); 1273 + } 1274 + } 1275 + } 1276 + button { 1277 + class: "add-field-widget-edit", 1278 + onclick: move |_| { 1279 + let name = field_name(); 1280 + let value_text = field_value(); 1281 + 1282 + if name.is_empty() { 1283 + error.set(Some("Field name required".to_string())); 1284 + return; 1285 + } 1286 + 1287 + let new_value = match infer_data_from_text(&value_text) { 1288 + Ok(data) => data, 1289 + Err(e) => { 1290 + error.set(Some(e)); 1291 + return; 1292 + } 1293 + }; 1294 + 1295 + let mut new_data = root.read().clone(); 1296 + if let Some(Data::Object(obj)) = new_data.get_at_path_mut(path_for_button.as_str()) { 1297 + obj.0.insert(name.into(), new_value); 1298 + } 1299 + root.set(new_data); 1300 + 1301 + // Reset form 1302 + field_name.set(String::new()); 1303 + field_value.set(String::new()); 1304 + show_form.set(false); 1305 + error.set(None); 1306 + }, 1307 + "Add" 1308 + } 1309 + button { 1310 + class: "add-field-widget-edit", 1311 + onclick: move |_| { 1312 + show_form.set(false); 1313 + field_name.set(String::new()); 1314 + field_value.set(String::new()); 1315 + error.set(None); 1316 + }, 1317 + "Cancel" 1318 + } 1319 + if let Some(err) = error() { 1320 + div { class: "field-error", "❌ {err}" } 1321 + } 1322 + } 1323 + } 1324 + } 1325 + } 1326 + } 1327 + 1328 + /// Layout component for record view - handles header, metadata, and wraps children 1329 + #[component] 1330 + fn RecordViewLayout(uri: AtUri<'static>, cid: Option<Cid<'static>>, children: Element) -> Element { 1331 + rsx! { 1332 + document::Stylesheet { href: asset!("/assets/styling/record-view.css") } 1333 + div { 1334 + class: "record-view-container", 1335 + div { 1336 + class: "record-header", 1337 + h1 { "Record" } 1338 + div { 1339 + class: "record-metadata", 1340 + div { class: "metadata-row", 1341 + span { class: "metadata-label", "URI" } 1342 + span { class: "metadata-value", 1343 + HighlightedUri { uri: uri.clone() } 1344 + } 1345 + } 1346 + if let Some(cid) = cid { 1347 + div { class: "metadata-row", 1348 + span { class: "metadata-label", "CID" } 1349 + code { class: "metadata-value", "{cid}" } 1350 + } 1351 + } 1352 + } 1353 + } 1354 + {children} 1355 + } 1356 + } 1357 + } 1358 + 875 1359 /// Render some text as markdown. 1360 + #[component] 1361 + fn EditableRecordContent( 1362 + record_value: Signal<Data<'static>>, 1363 + uri: ReadSignal<AtUri<'static>>, 1364 + view_mode: Signal<ViewMode>, 1365 + edit_mode: Signal<bool>, 1366 + record_resource: Resource<Result<GetRecordOutput<'static>, AgentError>>, 1367 + ) -> Element { 1368 + let mut edit_data = use_signal(|| record_value()); 1369 + let nsid = use_memo(move || edit_data().type_discriminator().map(|s| s.to_string())); 1370 + let navigator = use_navigator(); 1371 + let fetcher = use_context::<CachedFetcher>(); 1372 + 1373 + let update_fetcher = fetcher.clone(); 1374 + let create_fetcher = fetcher.clone(); 1375 + let replace_fetcher = fetcher.clone(); 1376 + let delete_fetcher = fetcher.clone(); 1377 + 1378 + rsx! { 1379 + div { 1380 + class: "tab-bar", 1381 + button { 1382 + class: if view_mode() == ViewMode::Pretty { "tab-button active" } else { "tab-button" }, 1383 + onclick: move |_| view_mode.set(ViewMode::Pretty), 1384 + "View" 1385 + } 1386 + button { 1387 + class: if view_mode() == ViewMode::Json { "tab-button active" } else { "tab-button" }, 1388 + onclick: move |_| view_mode.set(ViewMode::Json), 1389 + "JSON" 1390 + } 1391 + ActionButtons { 1392 + on_update: move |_| { 1393 + let fetcher = update_fetcher.clone(); 1394 + let uri = uri(); 1395 + let data = edit_data(); 1396 + spawn(async move { 1397 + if let Some((did, _)) = fetcher.session_info().await { 1398 + if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) { 1399 + let collection = Nsid::new(collection_str.as_str()).ok(); 1400 + if let Some(collection) = collection { 1401 + let request = PutRecord::new() 1402 + .repo(AtIdentifier::Did(did)) 1403 + .collection(collection) 1404 + .rkey(rkey.clone()) 1405 + .record(data.clone()) 1406 + .build(); 1407 + 1408 + match fetcher.send(request).await { 1409 + Ok(output) => { 1410 + if output.status() == StatusCode::OK { 1411 + dioxus_logger::tracing::info!("Record updated successfully"); 1412 + record_value.set(data); 1413 + edit_mode.set(false); 1414 + } else { 1415 + dioxus_logger::tracing::error!("Unexpected status code: {:?}", output.status()); 1416 + } 1417 + } 1418 + Err(e) => { 1419 + dioxus_logger::tracing::error!("Failed to update record: {:?}", e); 1420 + } 1421 + } 1422 + } 1423 + } 1424 + } 1425 + }); 1426 + }, 1427 + on_save_new: move |_| { 1428 + let fetcher = create_fetcher.clone(); 1429 + let data = edit_data(); 1430 + let nav = navigator.clone(); 1431 + spawn(async move { 1432 + if let Some((did, _)) = fetcher.session_info().await { 1433 + if let Some(collection_str) = data.type_discriminator() { 1434 + let collection = Nsid::new(collection_str).ok(); 1435 + if let Some(collection) = collection { 1436 + let request = CreateRecord::new() 1437 + .repo(AtIdentifier::Did(did)) 1438 + .collection(collection) 1439 + .record(data.clone()) 1440 + .build(); 1441 + 1442 + match fetcher.send(request).await { 1443 + Ok(response) => { 1444 + if let Ok(output) = response.into_output() { 1445 + dioxus_logger::tracing::info!("Record created: {}", output.uri); 1446 + nav.push(Route::RecordView { uri: output.uri.to_smolstr() }); 1447 + } 1448 + } 1449 + Err(e) => { 1450 + dioxus_logger::tracing::error!("Failed to create record: {:?}", e); 1451 + } 1452 + } 1453 + } 1454 + } 1455 + } 1456 + }); 1457 + }, 1458 + on_replace: move |_| { 1459 + let fetcher = replace_fetcher.clone(); 1460 + let uri = uri(); 1461 + let data = edit_data(); 1462 + let nav = navigator.clone(); 1463 + spawn(async move { 1464 + if let Some((did, _)) = fetcher.session_info().await { 1465 + if let Some(new_collection_str) = data.type_discriminator() { 1466 + let new_collection = Nsid::new(new_collection_str).ok(); 1467 + if let Some(new_collection) = new_collection { 1468 + // Create new record 1469 + let create_req = CreateRecord::new() 1470 + .repo(AtIdentifier::Did(did.clone())) 1471 + .collection(new_collection) 1472 + .record(data.clone()) 1473 + .build(); 1474 + 1475 + match fetcher.send(create_req).await { 1476 + Ok(response) => { 1477 + if let Ok(create_output) = response.into_output() { 1478 + // Delete old record 1479 + if let (Some(old_collection_str), Some(old_rkey)) = (uri.collection(), uri.rkey()) { 1480 + let old_collection = Nsid::new(old_collection_str.as_str()).ok(); 1481 + if let Some(old_collection) = old_collection { 1482 + let delete_req = DeleteRecord::new() 1483 + .repo(AtIdentifier::Did(did)) 1484 + .collection(old_collection) 1485 + .rkey(old_rkey.clone()) 1486 + .build(); 1487 + 1488 + if let Err(e) = fetcher.send(delete_req).await { 1489 + dioxus_logger::tracing::warn!("Created new record but failed to delete old: {:?}", e); 1490 + } 1491 + } 1492 + } 1493 + 1494 + dioxus_logger::tracing::info!("Record replaced: {}", create_output.uri); 1495 + nav.push(Route::RecordView { uri: create_output.uri.to_smolstr() }); 1496 + } 1497 + } 1498 + Err(e) => { 1499 + dioxus_logger::tracing::error!("Failed to replace record: {:?}", e); 1500 + } 1501 + } 1502 + } 1503 + } 1504 + } 1505 + }); 1506 + }, 1507 + on_delete: move |_| { 1508 + let fetcher = delete_fetcher.clone(); 1509 + let uri = uri(); 1510 + let nav = navigator.clone(); 1511 + spawn(async move { 1512 + if let Some((did, _)) = fetcher.session_info().await { 1513 + if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) { 1514 + let collection = Nsid::new(collection_str.as_str()).ok(); 1515 + if let Some(collection) = collection { 1516 + let request = DeleteRecord::new() 1517 + .repo(AtIdentifier::Did(did)) 1518 + .collection(collection) 1519 + .rkey(rkey.clone()) 1520 + .build(); 1521 + 1522 + match fetcher.send(request).await { 1523 + Ok(_) => { 1524 + dioxus_logger::tracing::info!("Record deleted"); 1525 + nav.push(Route::Home {}); 1526 + } 1527 + Err(e) => { 1528 + dioxus_logger::tracing::error!("Failed to delete record: {:?}", e); 1529 + } 1530 + } 1531 + } 1532 + } 1533 + } 1534 + }); 1535 + }, 1536 + on_cancel: move |_| { 1537 + edit_data.set(record_value()); 1538 + edit_mode.set(false); 1539 + }, 1540 + } 1541 + } 1542 + div { 1543 + class: "tab-content", 1544 + match view_mode() { 1545 + ViewMode::Pretty => rsx! { 1546 + div { class: "pretty-record", 1547 + EditableDataView { 1548 + root: edit_data, 1549 + path: String::new(), 1550 + did: uri().authority().to_string(), 1551 + } 1552 + } 1553 + }, 1554 + ViewMode::Json => rsx! { 1555 + JsonEditor { data: edit_data, nsid } 1556 + }, 1557 + } 1558 + } 1559 + } 1560 + } 1561 + 876 1562 #[component] 877 1563 pub fn CodeView(props: CodeViewProps) -> Element { 878 1564 let code = &*props.code.read();