atproto blogging
1#![allow(non_snake_case)]
2
3#[cfg(feature = "server")]
4use crate::blobcache::BlobCache;
5use crate::components::AuthorList;
6use crate::components::{AppLink, AppLinkTarget};
7use crate::{components::EntryActions, data::use_handle};
8use dioxus::prelude::*;
9use jacquard::types::aturi::AtUri;
10use jacquard::{IntoStatic, types::string::Handle};
11
12pub const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css");
13
14#[allow(unused_imports)]
15use jacquard::smol_str::ToSmolStr;
16use jacquard::types::string::Datetime;
17#[allow(unused_imports)]
18use jacquard::{
19 smol_str::SmolStr,
20 types::{cid::Cid, string::AtIdentifier},
21};
22#[allow(unused_imports)]
23use std::sync::Arc;
24use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, entry};
25
26#[component]
27pub fn EntryPage(
28 ident: ReadSignal<AtIdentifier<'static>>,
29 book_title: ReadSignal<SmolStr>,
30 title: ReadSignal<SmolStr>,
31) -> Element {
32 // Use feature-gated hook for SSR support
33 let (entry_res, entry) = crate::data::use_entry_data(ident, book_title, title);
34
35 // Track props for change detection (works with both Route and SubdomainRoute)
36 let mut last_title = use_signal(|| title().clone());
37
38 #[cfg(all(
39 target_family = "wasm",
40 target_os = "unknown",
41 not(feature = "fullstack-server")
42 ))]
43 let fetcher = use_context::<crate::fetch::Fetcher>();
44
45 // Suspend SSR until entry loads
46 #[cfg(feature = "fullstack-server")]
47 let mut entry_res = entry_res?;
48
49 #[cfg(feature = "fullstack-server")]
50 use_effect(use_reactive!(|title| {
51 if title != last_title() {
52 tracing::debug!("[EntryPage] title changed, restarting resource");
53 entry_res.restart();
54 last_title.set(title());
55 }
56 }));
57
58 // Debug: log route params and entry state
59 tracing::debug!(
60 "[EntryPage] route params: ident={:?}, book_title={:?}, title={:?}",
61 ident(),
62 book_title(),
63 title()
64 );
65 tracing::debug!(
66 "[EntryPage] rendering, entry.is_some={}",
67 entry.read().is_some()
68 );
69
70 // Handle blob caching when entry data is available
71 // Use read() instead of read_unchecked() for proper reactive tracking
72 match &*entry.read() {
73 Some((book_entry_view, entry_record)) => {
74 rsx! { EntryPageView {
75 book_entry_view: book_entry_view.clone(),
76 entry_record: entry_record.clone(),
77 ident: ident(),
78 book_title: book_title()
79 } }
80 }
81 _ => rsx! { p { "Loading..." } },
82 }
83}
84
85/// Calculate word count and estimated reading time (in minutes) for content
86pub fn calculate_reading_stats(content: &str) -> (usize, usize) {
87 let word_count = content.split_whitespace().count();
88 let reading_time_mins = (word_count + 199) / 200; // ~200 wpm, rounded up
89 (word_count, reading_time_mins.max(1))
90}
91
92/// Extract a plain-text preview from markdown content (first ~160 chars)
93pub fn extract_preview(content: &str, max_len: usize) -> String {
94 // Simple extraction: skip markdown syntax, get plain text
95 let plain: String = content
96 .lines()
97 .filter(|line| {
98 let trimmed = line.trim();
99 // Skip headings, images, links, code blocks
100 !trimmed.starts_with('#')
101 && !trimmed.starts_with('!')
102 && !trimmed.starts_with("```")
103 && !trimmed.is_empty()
104 })
105 .take(5)
106 .collect::<Vec<_>>()
107 .join(" ");
108
109 // Clean up markdown inline syntax
110 let cleaned = plain
111 .replace("**", "")
112 .replace("__", "")
113 .replace('*', "")
114 .replace('_', "")
115 .replace('`', "");
116
117 if cleaned.len() <= max_len {
118 cleaned
119 } else {
120 // Use char boundary-safe truncation to avoid panic on multibyte chars
121 let truncated: String = cleaned.chars().take(max_len - 3).collect();
122 format!("{}...", truncated)
123 }
124}
125
126/// OpenGraph and Twitter Card meta tags for entries
127#[component]
128pub fn EntryOgMeta(
129 title: String,
130 description: String,
131 image_url: String,
132 canonical_url: String,
133 author_handle: String,
134 #[props(default)] book_title: Option<String>,
135) -> Element {
136 let page_title = if let Some(ref book) = book_title {
137 format!("{} | {} | Weaver", title, book)
138 } else {
139 format!("{} | Weaver", title)
140 };
141
142 rsx! {
143 document::Title { "{page_title}" }
144 document::Meta { property: "og:title", content: "{title}" }
145 document::Meta { property: "og:description", content: "{description}" }
146 document::Meta { property: "og:image", content: "{image_url}" }
147 document::Meta { property: "og:type", content: "article" }
148 document::Meta { property: "og:url", content: "{canonical_url}" }
149 document::Meta { property: "og:site_name", content: "Weaver" }
150 document::Meta { name: "twitter:card", content: "summary_large_image" }
151 document::Meta { name: "twitter:title", content: "{title}" }
152 document::Meta { name: "twitter:description", content: "{description}" }
153 document::Meta { name: "twitter:image", content: "{image_url}" }
154 document::Meta { name: "twitter:creator", content: "@{author_handle}" }
155 }
156}
157
158/// Full entry page with metadata, content, and navigation
159#[component]
160fn EntryPageView(
161 book_entry_view: ReadSignal<BookEntryView<'static>>,
162 entry_record: ReadSignal<entry::Entry<'static>>,
163 ident: ReadSignal<AtIdentifier<'static>>,
164 book_title: ReadSignal<SmolStr>,
165) -> Element {
166 // Extract metadata
167 let entry_view = &book_entry_view().entry;
168 let title = entry_view
169 .title
170 .as_ref()
171 .map(|t| t.as_ref())
172 .unwrap_or("Untitled");
173
174 // Get entry path for URLs
175 let entry_path = entry_view
176 .path
177 .as_ref()
178 .map(|p| p.as_ref().to_string())
179 .unwrap_or_else(|| title.to_string());
180
181 // Get author handle
182 let author_handle = entry_view
183 .authors
184 .first()
185 .map(|a| {
186 use weaver_api::sh_weaver::actor::ProfileDataViewInner;
187 match &a.record.inner {
188 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(),
189 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(),
190 ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(),
191 _ => "unknown".to_string(),
192 }
193 })
194 .unwrap_or_else(|| "unknown".to_string());
195
196 // Build OG URLs
197 let base = if crate::env::WEAVER_APP_ENV == "dev" {
198 format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT)
199 } else {
200 crate::env::WEAVER_APP_HOST.to_string()
201 };
202 let canonical_url = format!("{}/{}/{}/{}", base, ident(), book_title(), entry_path);
203 let og_image_url = format!(
204 "{}/og/{}/{}/{}.png",
205 base,
206 ident(),
207 book_title(),
208 entry_path
209 );
210
211 // Extract description preview from content
212 let description = extract_preview(entry_record().content.as_ref(), 160);
213
214 tracing::info!("Entry: {book_title} - {title}");
215
216 rsx! {
217 EntryOgMeta {
218 title: title.to_string(),
219 description: description,
220 image_url: og_image_url,
221 canonical_url: canonical_url,
222 author_handle: author_handle,
223 book_title: Some(book_title().to_string()),
224 }
225 document::Link { rel: "stylesheet", href: ENTRY_CSS }
226
227 div { class: "entry-page",
228 // Header: nav prev + metadata + nav next
229 header { class: "entry-header",
230 if let Some(ref prev) = book_entry_view().prev {
231 NavButton {
232 direction: "prev",
233 entry: prev.entry.clone(),
234 ident: ident(),
235 book_title: book_title()
236 }
237 } else {
238 div { class: "nav-placeholder" }
239 }
240
241 {
242 let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record().content);
243 rsx! {
244 EntryMetadata {
245 entry_view: entry_view.clone(),
246 created_at: entry_record().created_at.clone(),
247 entry_uri: entry_view.uri.clone().into_static(),
248 book_title: Some(book_title()),
249 ident: ident(),
250 word_count: Some(word_count),
251 reading_time_mins: Some(reading_time_mins)
252 }
253 }
254 }
255
256 if let Some(ref next) = book_entry_view().next {
257 NavButton {
258 direction: "next",
259 entry: next.entry.clone(),
260 ident: ident(),
261 book_title: book_title()
262 }
263 } else {
264 div { class: "nav-placeholder" }
265 }
266 }
267
268 // Main content area
269 div { class: "entry-content-wrapper",
270 div { class: "entry-content-main notebook-content",
271 EntryMarkdown {
272 content: entry_record,
273 ident
274 }
275 }
276 }
277
278 // Footer navigation
279 footer { class: "entry-footer-nav",
280 if let Some(ref prev) = book_entry_view().prev {
281 NavButton {
282 direction: "prev",
283 entry: prev.entry.clone(),
284 ident: ident(),
285 book_title: book_title()
286 }
287 }
288
289 if let Some(ref next) = book_entry_view().next {
290 NavButton {
291 direction: "next",
292 entry: next.entry.clone(),
293 ident: ident(),
294 book_title: book_title()
295 }
296 }
297 }
298 }
299 }
300}
301
302#[component]
303pub fn EntryCard(
304 entry: BookEntryView<'static>,
305 book_title: SmolStr,
306 author_count: usize,
307 ident: AtIdentifier<'static>,
308) -> Element {
309 use crate::auth::AuthState;
310 use jacquard::from_data;
311 use weaver_api::sh_weaver::notebook::entry::Entry;
312
313 let mut hidden = use_signal(|| false);
314
315 // If removed from notebook, hide this card
316 if hidden() {
317 return rsx! {};
318 }
319
320 let auth_state = use_context::<Signal<AuthState>>();
321
322 let entry_view = &entry.entry;
323 let title = entry_view
324 .title
325 .as_ref()
326 .map(|t| t.as_ref())
327 .unwrap_or("Untitled");
328
329 // Get path from view for URL, fallback to title
330 let entry_path = entry_view
331 .path
332 .as_ref()
333 .map(|p| p.as_ref().to_string())
334 .unwrap_or_else(|| title.to_string());
335
336 // Parse entry record for content preview
337 let parsed_entry = from_data::<Entry>(&entry_view.record).ok();
338
339 // Format date
340 let formatted_date = entry_view
341 .indexed_at
342 .as_ref()
343 .format("%B %d, %Y")
344 .to_string();
345
346 // Check edit access via permissions
347 let can_edit = {
348 let current_did = auth_state.read().did.clone();
349 match ¤t_did {
350 Some(did) => {
351 if let Some(ref perms) = entry_view.permissions {
352 perms.editors.iter().any(|grant| grant.did == *did)
353 } else {
354 // Fall back to ownership check
355 match &ident {
356 AtIdentifier::Did(ident_did) => *did == *ident_did,
357 _ => false,
358 }
359 }
360 }
361 None => false,
362 }
363 };
364
365 let entry_uri = entry_view.uri.clone().into_static();
366
367 // Show author list if notebook has multiple authors
368 let show_author = author_count > 1;
369
370 // Render preview from truncated entry content
371 let preview_html = parsed_entry.as_ref().map(|entry| {
372 let parser = markdown_weaver::Parser::new(&entry.content);
373 let mut html_buf = String::new();
374 markdown_weaver::html::push_html(&mut html_buf, parser);
375 html_buf
376 });
377
378 // Calculate reading stats
379 let reading_stats = parsed_entry
380 .as_ref()
381 .map(|entry| calculate_reading_stats(&entry.content));
382
383 rsx! {
384 div { class: "entry-card",
385 div { class: "entry-card-meta",
386 div { class: "entry-card-header",
387 AppLink {
388 to: AppLinkTarget::Entry {
389 ident: ident.clone(),
390 book_title: book_title.clone(),
391 entry_path: entry_path.clone().into(),
392 },
393 class: Some("entry-card-title-link".to_string()),
394 h3 { class: "entry-card-title", "{title}" }
395 }
396 div { class: "entry-card-date",
397 time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" }
398 }
399 if can_edit {
400 EntryActions {
401 entry_uri,
402 entry_cid: entry_view.cid.clone().into_static(),
403 entry_title: title.to_string(),
404 in_notebook: true,
405 notebook_title: Some(book_title.clone()),
406 permissions: entry_view.permissions.clone(),
407 on_removed: Some(EventHandler::new(move |_| hidden.set(true)))
408 }
409 }
410 }
411 if show_author && !entry_view.authors.is_empty() {
412 AuthorList {
413 authors: entry_view.authors.clone(),
414 owner_ident: Some(ident.clone()),
415 class: Some("entry-card-author".to_string()),
416 }
417 }
418 }
419
420 if let Some(ref html) = preview_html {
421 div { class: "entry-card-preview", dangerous_inner_html: "{html}" }
422 }
423 if let Some(ref tags) = entry_view.tags {
424 if !tags.is_empty() {
425 div { class: "entry-card-tags",
426 for tag in tags.iter() {
427 span { class: "entry-card-tag", "{tag}" }
428 }
429 }
430 }
431 }
432 if let Some((words, mins)) = reading_stats {
433 div { class: "entry-card-stats",
434 span { class: "word-count", "{words} words" }
435 span { class: "reading-time", "{mins} min read" }
436 }
437 }
438 }
439 }
440}
441
442/// Card for entries in a feed (e.g., home page)
443/// Takes EntryView directly (not BookEntryView)
444#[component]
445pub fn FeedEntryCard(
446 entry_view: EntryView<'static>,
447 entry: entry::Entry<'static>,
448 #[props(default = false)] show_actions: bool,
449 #[props(default = false)] is_pinned: bool,
450 #[props(default = true)] show_author: bool,
451 /// Profile identity for context-aware author visibility (hides single author on their own profile)
452 #[props(default)]
453 profile_ident: Option<AtIdentifier<'static>>,
454 #[props(default)] on_pinned_changed: Option<EventHandler<bool>>,
455) -> Element {
456 use crate::auth::AuthState;
457
458 let title = entry_view
459 .title
460 .as_ref()
461 .map(|t| t.as_ref())
462 .unwrap_or("Untitled");
463
464 // Extract DID and rkey from the entry URI
465 let uri = &entry_view.uri;
466 let parsed_uri = jacquard::types::aturi::AtUri::new(uri.as_ref()).ok();
467
468 let ident = parsed_uri
469 .as_ref()
470 .map(|u| u.authority().clone().into_static())
471 .unwrap_or_else(|| AtIdentifier::Handle(Handle::new_static("invalid.handle").unwrap()));
472
473 let rkey: SmolStr = parsed_uri
474 .as_ref()
475 .and_then(|u| u.rkey().map(|r| SmolStr::new(r.0.as_str())))
476 .unwrap_or_default();
477
478 // Format date from record's created_at
479 let formatted_date = entry.created_at.as_ref().format("%B %d, %Y").to_string();
480
481 // Whether to show authors
482 let has_authors = show_author && !entry_view.authors.is_empty();
483
484 // Check edit access via permissions
485 let auth_state = use_context::<Signal<AuthState>>();
486 let can_edit = {
487 let current_did = auth_state.read().did.clone();
488 match ¤t_did {
489 Some(did) => {
490 if let Some(ref perms) = entry_view.permissions {
491 perms.editors.iter().any(|grant| grant.did == *did)
492 } else {
493 // Fall back to ownership check
494 match &ident {
495 AtIdentifier::Did(ident_did) => *did == *ident_did,
496 _ => false,
497 }
498 }
499 }
500 None => false,
501 }
502 };
503
504 // Render preview from truncated entry content
505 let preview_html = {
506 let parser = markdown_weaver::Parser::new(&entry.content);
507 let mut html_buf = String::new();
508 markdown_weaver::html::push_html(&mut html_buf, parser);
509 html_buf
510 };
511
512 // Calculate reading stats
513 let (word_count, reading_time_mins) = calculate_reading_stats(&entry.content);
514
515 rsx! {
516 div { class: "entry-card feed-entry-card",
517 // Header: title (and date if no author)
518 div { class: "entry-card-header",
519 AppLink {
520 to: AppLinkTarget::StandaloneEntry {
521 ident: ident.clone(),
522 rkey: rkey.clone(),
523 },
524 class: Some("entry-card-title-link".to_string()),
525 h3 { class: "entry-card-title", "{title}" }
526 }
527 // Date inline with title when no author shown
528 if !has_authors {
529 div { class: "entry-card-date",
530 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" }
531 }
532 }
533 if show_actions && can_edit {
534 crate::components::EntryActions {
535 entry_uri: entry_view.uri.clone().into_static(),
536 entry_cid: entry_view.cid.clone().into_static(),
537 entry_title: title.to_string(),
538 in_notebook: false,
539 is_pinned,
540 permissions: entry_view.permissions.clone(),
541 on_pinned_changed
542 }
543 }
544 }
545
546 // Byline: author + date (only when authors shown)
547 if has_authors {
548 div { class: "entry-card-byline",
549 AuthorList {
550 authors: entry_view.authors.clone(),
551 profile_ident: profile_ident.clone(),
552 owner_ident: Some(ident.clone()),
553 class: Some("entry-card-author".to_string()),
554 }
555 div { class: "entry-card-date",
556 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" }
557 }
558 }
559 }
560
561 div { class: "entry-card-preview", dangerous_inner_html: "{preview_html}" }
562 if let Some(ref tags) = entry_view.tags {
563 if !tags.is_empty() {
564 div { class: "entry-card-tags",
565 for tag in tags.iter() {
566 span { class: "entry-card-tag", "{tag}" }
567 }
568 }
569 }
570 }
571 div { class: "entry-card-stats",
572 span { class: "word-count", "{word_count} words" }
573 span { class: "reading-time", "{reading_time_mins} min read" }
574 }
575 }
576 }
577}
578
579/// Metadata header showing title, authors, date, tags, reading stats
580#[component]
581pub fn EntryMetadata(
582 entry_view: EntryView<'static>,
583 created_at: Datetime,
584 entry_uri: AtUri<'static>,
585 book_title: Option<SmolStr>,
586 ident: AtIdentifier<'static>,
587 #[props(default)] word_count: Option<usize>,
588 #[props(default)] reading_time_mins: Option<usize>,
589) -> Element {
590 use crate::components::use_app_navigate;
591
592 let navigate = use_app_navigate();
593
594 let title = entry_view
595 .title
596 .as_ref()
597 .map(|t| t.as_ref())
598 .unwrap_or("Untitled");
599
600 let entry_title = title.to_string();
601
602 // Navigate back to notebook when entry is removed
603 let nav_book_title = book_title.clone();
604 let nav_ident = ident.clone();
605 let on_removed = move |_| {
606 if let Some(ref title) = nav_book_title {
607 navigate(AppLinkTarget::Notebook {
608 ident: nav_ident.clone(),
609 book_title: title.clone(),
610 });
611 }
612 };
613
614 rsx! {
615 header { class: "entry-metadata",
616 div { class: "entry-header-row",
617 h1 { class: "entry-title", "{title}" }
618 EntryActions {
619 entry_uri: entry_uri.clone(),
620 entry_cid: entry_view.cid.clone().into_static(),
621 entry_title,
622 in_notebook: book_title.is_some(),
623 notebook_title: book_title.clone(),
624 permissions: entry_view.permissions.clone(),
625 on_removed: Some(EventHandler::new(on_removed))
626 }
627 }
628
629 div { class: "entry-meta-info",
630 // Authors
631 div { class: "entry-authors",
632 AuthorList {
633 authors: entry_view.authors.clone(),
634 owner_ident: Some(ident.clone()),
635 }
636 }
637
638
639 // Date
640 div { class: "entry-date",
641 {
642 let formatted_date = created_at.as_ref().format("%B %d, %Y").to_string();
643
644 rsx! {
645 time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" }
646
647 }
648 }
649 }
650
651 // Tags and reading stats on their own line
652 div { class: "entry-meta-secondary",
653 if let Some(ref tags) = entry_view.tags {
654 div { class: "entry-tags",
655 span { class: "meta-label", "Tags:" }
656 for tag in tags.iter() {
657 span { class: "entry-tag", "{tag}" }
658 }
659 }
660 }
661
662 if let (Some(words), Some(mins)) = (word_count, reading_time_mins) {
663 div { class: "entry-reading-stats",
664 span { class: "word-count", "{words} words" }
665 span { class: "reading-time", "{mins} min read" }
666 }
667 }
668 }
669 }
670 }
671 }
672}
673
674/// Navigation link for prev/next entries (minimal: arrow + title)
675#[component]
676pub fn NavButton(
677 direction: &'static str,
678 entry: EntryView<'static>,
679 ident: AtIdentifier<'static>,
680 book_title: SmolStr,
681) -> Element {
682 let entry_title = entry
683 .title
684 .as_ref()
685 .map(|t| t.as_ref())
686 .unwrap_or("Untitled");
687
688 let entry_path = entry
689 .path
690 .as_ref()
691 .map(|p| p.as_ref().to_string())
692 .unwrap_or_else(|| entry_title.to_string());
693
694 let (arrow, title_first) = if direction == "prev" {
695 ("←", false)
696 } else {
697 ("→", true)
698 };
699
700 rsx! {
701 AppLink {
702 to: AppLinkTarget::Entry {
703 ident: ident.clone(),
704 book_title: book_title.clone(),
705 entry_path: entry_path.into(),
706 },
707 class: Some(format!("nav-button nav-button-{}", direction)),
708 if title_first {
709 span { class: "nav-title", "{entry_title}" }
710 span { class: "nav-arrow", "{arrow}" }
711 } else {
712 span { class: "nav-arrow", "{arrow}" }
713 span { class: "nav-title", "{entry_title}" }
714 }
715 }
716 }
717}
718
719#[derive(Props, Clone, PartialEq)]
720pub struct EntryMarkdownProps {
721 #[props(default)]
722 id: Signal<String>,
723 #[props(default = use_signal(||"entry".to_string()))]
724 class: Signal<String>,
725 content: ReadSignal<entry::Entry<'static>>,
726 ident: ReadSignal<AtIdentifier<'static>>,
727}
728
729/// Render some text as markdown.
730pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element {
731 let (mut _res, processed) = crate::data::use_rendered_markdown(props.content, props.ident);
732
733 // Track entry title to detect content change and restart resource
734 let mut last_title = use_signal(|| (props.content)().title.to_string());
735 let current_title = (props.content)().title.to_string();
736 if current_title != last_title() {
737 #[cfg(feature = "fullstack-server")]
738 if let Ok(ref mut r) = _res {
739 r.restart();
740 }
741 last_title.set(current_title);
742 }
743
744 #[cfg(feature = "fullstack-server")]
745 _res?;
746
747 match &*processed.read() {
748 Some(html_buf) => rsx! {
749 div {
750 id: "{&*props.id.read()}",
751 class: "{&*props.class.read()}",
752 dangerous_inner_html: "{html_buf}"
753 }
754 },
755 _ => rsx! {
756 div {
757 id: "{&*props.id.read()}",
758 class: "{&*props.class.read()}",
759 "Loading..."
760 }
761 },
762 }
763}
764
765/// Render entry content directly without signals
766#[component]
767fn EntryMarkdownDirect(
768 #[props(default)] id: String,
769 #[props(default = "entry".to_string())] class: String,
770 content: entry::Entry<'static>,
771 ident: AtIdentifier<'static>,
772) -> Element {
773 // Use feature-gated hook for SSR support
774 let content = use_signal(|| content);
775 let ident = use_signal(|| ident);
776 let (_res, processed) = crate::data::use_rendered_markdown(content.into(), ident.into());
777 #[cfg(feature = "fullstack-server")]
778 _res?;
779
780 match &*processed.read() {
781 Some(html_buf) => rsx! {
782 div {
783 id: "{id}",
784 class: "{class}",
785 dangerous_inner_html: "{html_buf}"
786 }
787 },
788 _ => rsx! {
789 div {
790 id: "{id}",
791 class: "{class}",
792 "Loading..."
793 }
794 },
795 }
796}