WIP editor

Orual 3e94583b e8840cba

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