atproto blogging
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}