at main 610 lines 24 kB view raw
1use crate::components::accordion::{Accordion, AccordionContent, AccordionItem, AccordionTrigger}; 2use crate::record_utils::{get_errors_at_exact_path, get_expected_string_format, get_hex_rep}; 3use dioxus::prelude::*; 4use humansize::format_size; 5use jacquard::to_data; 6use jacquard::types::string::AtprotoStr; 7use jacquard::{ 8 common::{Data, IntoStatic}, 9 types::{aturi::AtUri, cid::Cid}, 10}; 11use jacquard_lexicon::lexicon::LexiconDoc; 12use jacquard_lexicon::validation::ValidationResult; 13use weaver_renderer::{code_pretty::highlight_code, css::generate_default_css}; 14 15#[derive(Clone, Copy, PartialEq)] 16pub enum ViewMode { 17 Pretty, 18 Json, 19 Schema, 20} 21 22/// Layout component for record view - handles header, metadata, and wraps children 23#[component] 24pub fn RecordViewLayout( 25 uri: AtUri<'static>, 26 cid: Option<Cid<'static>>, 27 schema: ReadSignal<Option<LexiconDoc<'static>>>, 28 record_value: Data<'static>, 29 children: Element, 30) -> Element { 31 // Validate the record if schema is available 32 let validation_status = use_memo(move || { 33 let _schema_doc = schema()?; 34 let nsid_str = record_value.type_discriminator()?; 35 36 let validator = jacquard_lexicon::validation::SchemaValidator::global(); 37 let result = validator.validate_by_nsid(nsid_str, &record_value); 38 39 Some(result.is_valid()) 40 }); 41 42 rsx! { 43 div { 44 class: "record-metadata", 45 div { class: "metadata-row", 46 span { class: "metadata-label", "URI" } 47 span { class: "metadata-value", 48 HighlightedUri { uri: uri.clone() } 49 } 50 } 51 if let Some(cid) = cid { 52 div { class: "metadata-row", 53 span { class: "metadata-label", "CID" } 54 code { class: "metadata-value", "{cid}" } 55 } 56 } 57 if let Some(is_valid) = validation_status() { 58 div { class: "metadata-row", 59 span { class: "metadata-label", "Schema" } 60 span { 61 class: if is_valid { "metadata-value schema-valid" } else { "metadata-value schema-invalid" }, 62 if is_valid { "Valid" } else { "Invalid" } 63 } 64 } 65 } 66 } 67 68 {children} 69 70 } 71} 72 73#[component] 74pub fn SchemaView(schema: ReadSignal<Option<LexiconDoc<'static>>>) -> Element { 75 if let Some(schema_doc) = schema() { 76 // Convert LexiconDoc to Data for display 77 let schema_data = to_data(&schema_doc).ok().map(|d| d.into_static()); 78 79 if let Some(data) = schema_data { 80 rsx! { 81 div { 82 class: "pretty-record", 83 DataView { data: data.clone(), root_data: data, path: String::new(), did: String::new() } 84 } 85 } 86 } else { 87 rsx! { 88 div { class: "schema-error", "Failed to convert schema to displayable format" } 89 } 90 } 91 } else { 92 rsx! { 93 div { class: "schema-loading", "Loading schema..." } 94 } 95 } 96} 97 98#[component] 99pub fn DataView( 100 data: Data<'static>, 101 root_data: ReadSignal<Data<'static>>, 102 path: String, 103 did: String, 104) -> Element { 105 // Try to get validation result from context and get errors exactly at this path 106 let validation_result = try_use_context::<Signal<Option<ValidationResult>>>(); 107 108 let errors = if let Some(vr_signal) = validation_result { 109 get_errors_at_exact_path(&*vr_signal.read(), &path) 110 } else { 111 Vec::new() 112 }; 113 114 let has_errors = !errors.is_empty(); 115 116 match &data { 117 Data::Null => rsx! { 118 div { class: if has_errors { "record-field field-error" } else { "record-field" }, 119 PathLabel { path: path.clone() } 120 span { class: "field-value muted", "null" } 121 if has_errors { 122 for error in &errors { 123 div { class: "field-error-message", "{error}" } 124 } 125 } 126 } 127 }, 128 Data::Boolean(b) => rsx! { 129 div { class: if has_errors { "record-field field-error" } else { "record-field" }, 130 PathLabel { path: path.clone() } 131 span { class: "field-value", "{b}" } 132 if has_errors { 133 for error in &errors { 134 div { class: "field-error-message", "{error}" } 135 } 136 } 137 } 138 }, 139 Data::Integer(i) => rsx! { 140 div { class: if has_errors { "record-field field-error" } else { "record-field" }, 141 PathLabel { path: path.clone() } 142 span { class: "field-value", "{i}" } 143 if has_errors { 144 for error in &errors { 145 div { class: "field-error-message", "{error}" } 146 } 147 } 148 } 149 }, 150 Data::String(s) => { 151 use jacquard::types::string::AtprotoStr; 152 use jacquard_lexicon::lexicon::LexStringFormat; 153 154 // Get expected format from schema 155 let expected_format = get_expected_string_format(&*root_data.read(), &path); 156 157 // Get actual type from data 158 let actual_type_label = match s { 159 AtprotoStr::Datetime(_) => "datetime", 160 AtprotoStr::Language(_) => "language", 161 AtprotoStr::Tid(_) => "tid", 162 AtprotoStr::Nsid(_) => "nsid", 163 AtprotoStr::Did(_) => "did", 164 AtprotoStr::Handle(_) => "handle", 165 AtprotoStr::AtIdentifier(_) => "at-identifier", 166 AtprotoStr::AtUri(_) => "at-uri", 167 AtprotoStr::Uri(_) => "uri", 168 AtprotoStr::Cid(_) => "cid", 169 AtprotoStr::RecordKey(_) => "record-key", 170 AtprotoStr::String(_) => "string", 171 }; 172 173 // Prefer schema format if available, otherwise use actual type 174 let type_label = if let Some(fmt) = expected_format { 175 match fmt { 176 LexStringFormat::Datetime => "datetime", 177 LexStringFormat::Uri => "uri", 178 LexStringFormat::AtUri => "at-uri", 179 LexStringFormat::Did => "did", 180 LexStringFormat::Handle => "handle", 181 LexStringFormat::AtIdentifier => "at-identifier", 182 LexStringFormat::Nsid => "nsid", 183 LexStringFormat::Cid => "cid", 184 LexStringFormat::Language => "language", 185 LexStringFormat::Tid => "tid", 186 LexStringFormat::RecordKey => "record-key", 187 } 188 } else { 189 actual_type_label 190 }; 191 192 rsx! { 193 div { class: if has_errors { "record-field field-error" } else { "record-field" }, 194 PathLabel { path: path.clone() } 195 span { class: "field-value", 196 197 HighlightedString { string_type: s.clone() } 198 if type_label != "string" { 199 span { class: "string-type-tag", " [{type_label}]" } 200 } 201 } 202 if has_errors { 203 for error in &errors { 204 div { class: "field-error-message", "{error}" } 205 } 206 } 207 } 208 } 209 } 210 Data::Bytes(b) => { 211 let hex_string = get_hex_rep(&mut b.to_vec()); 212 let byte_size = if b.len() > 128 { 213 format_size(b.len(), humansize::BINARY) 214 } else { 215 format!("{} bytes", b.len()) 216 }; 217 rsx! { 218 div { class: if has_errors { "record-field field-error" } else { "record-field" }, 219 PathLabel { path: path.clone() } 220 pre { class: "field-value bytes", "{hex_string} [{byte_size}]" } 221 if has_errors { 222 for error in &errors { 223 div { class: "field-error-message", "{error}" } 224 } 225 } 226 } 227 } 228 } 229 Data::CidLink(cid) => rsx! { 230 div { class: if has_errors { "record-field field-error" } else { "record-field" }, 231 span { class: "field-label", "{path}" } 232 span { class: "field-value", "{cid}" } 233 if has_errors { 234 for error in &errors { 235 div { class: "field-error-message", "{error}" } 236 } 237 } 238 } 239 }, 240 Data::Array(arr) => { 241 let label = path.split('.').last().unwrap_or(&path); 242 rsx! { 243 div { class: "record-section", 244 Accordion { 245 id: "array-{path}", 246 collapsible: true, 247 AccordionItem { 248 default_open: true, 249 index: 0, 250 AccordionTrigger { 251 div { class: "section-label", "{label}" span { class: "array-len", "[{arr.len()}]" } } 252 } 253 AccordionContent { 254 if has_errors { 255 for error in &errors { 256 div { class: "field-error-message", "{error}" } 257 } 258 } 259 div { class: "section-content", 260 for (idx, item) in arr.iter().enumerate() { 261 { 262 let item_path = format!("{}[{}]", label, idx); 263 let is_object = matches!(item, Data::Object(_)); 264 265 if is_object { 266 rsx! { 267 div { 268 class: "array-item", 269 div { class: "record-section", 270 div { class: "section-label", "{item_path}" } 271 div { class: "section-content", 272 DataView { 273 data: item.clone(), 274 root_data, 275 path: item_path.clone(), 276 did: did.clone() 277 } 278 } 279 } 280 } 281 } 282 } else { 283 rsx! { 284 div { 285 class: "array-item", 286 DataView { 287 data: item.clone(), 288 root_data, 289 path: item_path, 290 did: did.clone() 291 } 292 } 293 } 294 } 295 } 296 } 297 } 298 } 299 } 300 } 301 } 302 } 303 } 304 Data::Object(obj) => { 305 let is_root = path.is_empty(); 306 let is_array_item = path.split('.').last().unwrap_or(&path).contains('['); 307 308 if is_root || is_array_item { 309 // Root object or array item: just render children (array items already wrapped) 310 rsx! { 311 div { class: if !is_root { "record-section" } else {""}, 312 if has_errors { 313 for error in &errors { 314 div { class: "field-error-message", "{error}" } 315 } 316 } 317 for (key, value) in obj.iter() { 318 { 319 let new_path = if is_root { 320 key.to_string() 321 } else { 322 format!("{}.{}", path, key) 323 }; 324 let did_clone = did.clone(); 325 rsx! { 326 DataView { data: value.clone(), root_data, path: new_path, did: did_clone } 327 } 328 } 329 } 330 } 331 } 332 } else { 333 // Nested object (not array item): wrap in section 334 let label = path.split('.').last().unwrap_or(&path); 335 rsx! { 336 div { class: "record-section", 337 Accordion { 338 id: "object-{path}", 339 collapsible: true, 340 AccordionItem { 341 default_open: true, 342 index: 0, 343 AccordionTrigger { 344 div { class: "section-label", "{label}" } 345 } 346 AccordionContent { 347 if has_errors { 348 for error in &errors { 349 div { class: "field-error-message", "{error}" } 350 } 351 } 352 div { class: "section-content", 353 for (key, value) in obj.iter() { 354 { 355 let new_path = format!("{}.{}", path, key); 356 let did_clone = did.clone(); 357 rsx! { 358 DataView { data: value.clone(), root_data, path: new_path, did: did_clone } 359 } 360 } 361 } 362 } 363 } 364 } 365 } 366 } 367 } 368 } 369 } 370 Data::Blob(blob) => { 371 let is_image = blob.mime_type.starts_with("image/"); 372 let format = blob.mime_type.strip_prefix("image/").unwrap_or("jpeg"); 373 let image_url = format!( 374 "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 375 did, 376 blob.cid(), 377 format 378 ); 379 380 let blob_size = format_size(blob.size, humansize::BINARY); 381 rsx! { 382 div { class: "record-field", 383 span { class: "field-label", "{path}" } 384 span { class: "field-value mime", "[mimeType: {blob.mime_type}, size: {blob_size}]" } 385 if is_image { 386 img { 387 src: "{image_url}", 388 alt: "Blob image", 389 class: "blob-image", 390 } 391 } 392 } 393 } 394 } 395 } 396} 397 398#[component] 399pub fn HighlightedUri(uri: AtUri<'static>) -> Element { 400 let s = uri.as_str(); 401 let link = format!("{}/record/{}", crate::env::WEAVER_APP_HOST, s); 402 403 if let Some(rest) = s.strip_prefix("at://") { 404 let parts: Vec<&str> = rest.splitn(3, '/').collect(); 405 return rsx! { 406 a { 407 href: link, 408 class: "uri-link", 409 span { class: "string-at-uri", 410 span { class: "aturi-scheme", "at://" } 411 span { class: "aturi-authority", "{uri.authority()}" } 412 413 if parts.len() > 1 { 414 span { class: "aturi-slash", "/" } 415 if let Some(collection) = uri.collection() { 416 span { class: "aturi-collection", "{collection.as_ref()}" } 417 } 418 } 419 if parts.len() > 2 { 420 span { class: "aturi-slash", "/" } 421 if let Some(rkey) = uri.rkey() { 422 span { class: "aturi-rkey", "{rkey.as_ref()}" } 423 } 424 } 425 } 426 } 427 }; 428 } 429 430 rsx! { a { class: "string-at-uri", href: s } } 431} 432 433#[component] 434pub fn HighlightedString(string_type: AtprotoStr<'static>) -> Element { 435 use jacquard::types::string::AtprotoStr; 436 437 match &string_type { 438 AtprotoStr::Nsid(nsid) => { 439 let parts: Vec<&str> = nsid.as_str().split('.').collect(); 440 rsx! { 441 span { class: "string-nsid", 442 for (i, part) in parts.iter().enumerate() { 443 span { class: "nsid-segment nsid-segment-{i % 3}", "{part}" } 444 if i < parts.len() - 1 { 445 span { class: "nsid-dot", "." } 446 } 447 } 448 } 449 } 450 } 451 AtprotoStr::Did(did) => { 452 let s = did.as_str(); 453 if let Some(rest) = s.strip_prefix("did:") { 454 if let Some((method, identifier)) = rest.split_once(':') { 455 return rsx! { 456 span { class: "string-did", 457 span { class: "did-scheme", "did:" } 458 span { class: "did-method", "{method}" } 459 span { class: "did-separator", ":" } 460 span { class: "did-identifier", "{identifier}" } 461 } 462 }; 463 } 464 } 465 rsx! { span { class: "string-did", "{s}" } } 466 } 467 AtprotoStr::Handle(handle) => { 468 let parts: Vec<&str> = handle.as_str().split('.').collect(); 469 rsx! { 470 span { class: "string-handle", 471 for (i, part) in parts.iter().enumerate() { 472 span { class: "handle-segment handle-segment-{i % 2}", "{part}" } 473 if i < parts.len() - 1 { 474 span { class: "handle-dot", "." } 475 } 476 } 477 } 478 } 479 } 480 AtprotoStr::AtUri(uri) => { 481 rsx! { 482 HighlightedUri { uri: uri.clone().into_static() } 483 } 484 } 485 AtprotoStr::Uri(uri) => { 486 let s = uri.as_str(); 487 if let Ok(at_uri) = AtUri::new(s) { 488 return rsx! { 489 HighlightedUri { uri: at_uri.into_static() } 490 }; 491 } 492 493 // Try to parse scheme 494 if let Some((scheme, rest)) = s.split_once("://") { 495 // Split authority and path 496 let (authority, path) = if let Some(idx) = rest.find('/') { 497 (&rest[..idx], &rest[idx..]) 498 } else { 499 (rest, "") 500 }; 501 502 return rsx! { 503 a { 504 href: "{s}", 505 target: "_blank", 506 rel: "noopener noreferrer", 507 class: "uri-link", 508 span { class: "string-uri", 509 span { class: "uri-scheme", "{scheme}" } 510 span { class: "uri-separator", "://" } 511 span { class: "uri-authority", "{authority}" } 512 if !path.is_empty() { 513 span { class: "uri-path", "{path}" } 514 } 515 } 516 } 517 }; 518 } 519 520 rsx! { span { class: "string-uri", "{s}" } } 521 } 522 _ => { 523 let value = string_type.as_str(); 524 rsx! { "{value}" } 525 } 526 } 527} 528 529#[derive(Props, Clone, PartialEq)] 530pub struct CodeViewProps { 531 #[props(default)] 532 id: Signal<String>, 533 #[props(default)] 534 class: Signal<String>, 535 code: ReadSignal<String>, 536 lang: Option<String>, 537} 538 539#[component] 540pub fn PrettyRecordView( 541 record: Data<'static>, 542 uri: AtUri<'static>, 543 schema: ReadSignal<Option<LexiconDoc<'static>>>, 544) -> Element { 545 let did = uri.authority().to_string(); 546 let root_data = use_signal(|| record.clone()); 547 548 // Validate the record and provide via context - only after schema is loaded 549 let mut validation_result = use_signal(|| None); 550 use_effect(move || { 551 // Wait for schema to be loaded 552 if schema().is_some() { 553 if let Some(nsid_str) = root_data.read().type_discriminator() { 554 let validator = jacquard_lexicon::validation::SchemaValidator::global(); 555 let result = validator.validate_by_nsid(nsid_str, &*root_data.read()); 556 validation_result.set(Some(result)); 557 } 558 } 559 }); 560 use_context_provider(|| validation_result); 561 562 rsx! { 563 div { 564 class: "pretty-record", 565 DataView { data: record, root_data, path: String::new(), did } 566 } 567 } 568} 569 570#[component] 571pub fn CodeView(props: CodeViewProps) -> Element { 572 let code = &*props.code.read(); 573 574 let mut html_buf = String::new(); 575 highlight_code(props.lang.as_deref(), code, &mut html_buf).unwrap(); 576 577 rsx! { 578 document::Style { {generate_default_css().unwrap()}} 579 div { 580 id: "{&*props.id.read()}", 581 class: "{&*props.class.read()}", 582 dangerous_inner_html: "{html_buf}" 583 } 584 } 585} 586 587#[component] 588pub fn PathLabel(path: String) -> Element { 589 if path.is_empty() { 590 return rsx! {}; 591 } 592 593 // Find the last separator 594 let last_sep = path.rfind(|c| c == '.'); 595 596 if let Some(idx) = last_sep { 597 let prefix = &path[..idx + 1]; 598 let final_segment = &path[idx + 1..]; 599 rsx! { 600 span { class: "field-label", 601 span { class: "path-prefix", "{prefix}" } 602 span { class: "path-final", "{final_segment}" } 603 } 604 } 605 } else { 606 rsx! { 607 span { class: "field-label","{path}" } 608 } 609 } 610}