atproto blogging
1use crate::auth::AuthState;
2use crate::components::css::DefaultNotebookCss;
3use crate::components::{AuthorList, FeedEntryCard, ProfileActions, ProfileActionsMenubar};
4use crate::{Route, data, fetch};
5use dioxus::prelude::*;
6use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier};
7use std::collections::HashSet;
8use weaver_api::com_atproto::repo::strong_ref::StrongRef;
9use weaver_api::sh_weaver::notebook::{
10 BookEntryRef, BookEntryView, EntryView, NotebookView, entry::Entry,
11};
12
13/// Constructs BookEntryViews from notebook entry refs and all available entries.
14///
15/// Matches StrongRefs by URI to find the corresponding EntryView,
16/// then builds BookEntryView with index and prev/next navigation refs.
17fn build_book_entry_views(
18 entry_refs: &[StrongRef<'static>],
19 all_entries: &[(EntryView<'static>, Entry<'static>)],
20) -> Vec<BookEntryView<'static>> {
21 use jacquard::IntoStatic;
22
23 // Build a lookup map for faster matching
24 let entry_map: std::collections::HashMap<&str, &EntryView<'static>> = all_entries
25 .iter()
26 .map(|(view, _)| (view.uri.as_ref(), view))
27 .collect();
28
29 let mut views = Vec::with_capacity(entry_refs.len());
30
31 for (idx, strong_ref) in entry_refs.iter().enumerate() {
32 let Some(entry_view) = entry_map.get(strong_ref.uri.as_ref()).copied() else {
33 continue;
34 };
35
36 // Build prev ref (if not first)
37 let prev = if idx > 0 {
38 entry_refs
39 .get(idx - 1)
40 .and_then(|prev_ref| entry_map.get(prev_ref.uri.as_ref()).copied())
41 .map(|prev_view| {
42 BookEntryRef::new()
43 .entry(prev_view.clone())
44 .build()
45 .into_static()
46 })
47 } else {
48 None
49 };
50
51 // Build next ref (if not last)
52 let next = if idx + 1 < entry_refs.len() {
53 entry_refs
54 .get(idx + 1)
55 .and_then(|next_ref| entry_map.get(next_ref.uri.as_ref()).copied())
56 .map(|next_view| {
57 BookEntryRef::new()
58 .entry(next_view.clone())
59 .build()
60 .into_static()
61 })
62 } else {
63 None
64 };
65
66 views.push(
67 BookEntryView::new()
68 .entry(entry_view.clone())
69 .index(idx as i64)
70 .maybe_prev(prev)
71 .maybe_next(next)
72 .build()
73 .into_static(),
74 );
75 }
76
77 views
78}
79
80/// A single item in the profile timeline (either notebook or standalone entry)
81#[derive(Clone, PartialEq)]
82pub enum ProfileTimelineItem {
83 Notebook {
84 notebook: NotebookView<'static>,
85 entries: Vec<BookEntryView<'static>>,
86 /// Most recent entry's created_at for sorting
87 sort_date: jacquard::types::string::Datetime,
88 },
89 StandaloneEntry {
90 entry_view: EntryView<'static>,
91 entry: Entry<'static>,
92 },
93}
94
95impl ProfileTimelineItem {
96 pub fn sort_date(&self) -> &jacquard::types::string::Datetime {
97 match self {
98 Self::Notebook { sort_date, .. } => sort_date,
99 Self::StandaloneEntry { entry, .. } => &entry.created_at,
100 }
101 }
102}
103
104/// OpenGraph and Twitter Card meta tags for profile/repository pages
105#[component]
106pub fn ProfileOgMeta(
107 display_name: String,
108 handle: String,
109 bio: String,
110 image_url: String,
111 canonical_url: String,
112 notebook_count: usize,
113) -> Element {
114 let page_title = format!("{} (@{}) | Weaver", display_name, handle);
115 let full_description = if notebook_count > 0 {
116 format!("{} notebooks · {}", notebook_count, bio)
117 } else if bio.is_empty() {
118 format!("@{} on Weaver", handle)
119 } else {
120 bio.clone()
121 };
122
123 rsx! {
124 document::Title { "{page_title}" }
125 document::Meta { property: "og:title", content: "{display_name}" }
126 document::Meta { property: "og:description", content: "{full_description}" }
127 document::Meta { property: "og:image", content: "{image_url}" }
128 document::Meta { property: "og:type", content: "profile" }
129 document::Meta { property: "og:url", content: "{canonical_url}" }
130 document::Meta { property: "og:site_name", content: "Weaver" }
131 document::Meta { property: "profile:username", content: "{handle}" }
132 document::Meta { name: "twitter:card", content: "summary_large_image" }
133 document::Meta { name: "twitter:title", content: "{display_name}" }
134 document::Meta { name: "twitter:description", content: "{full_description}" }
135 document::Meta { name: "twitter:image", content: "{image_url}" }
136 document::Meta { name: "twitter:creator", content: "@{handle}" }
137 }
138}
139
140// Card styles (entry-card, notebook-card) loaded at navbar level
141const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css");
142const LAYOUTS_CSS: Asset = asset!("/assets/styling/layouts.css");
143
144#[component]
145pub fn Repository(ident: ReadSignal<AtIdentifier<'static>>) -> Element {
146 rsx! {
147 DefaultNotebookCss { }
148 document::Link { rel: "stylesheet", href: LAYOUTS_CSS }
149 document::Link { rel: "stylesheet", href: ENTRY_CSS }
150 div {
151 Outlet::<Route> {}
152 }
153 }
154}
155
156#[component]
157pub fn RepositoryIndex(ident: ReadSignal<AtIdentifier<'static>>) -> Element {
158 use crate::components::ProfileDisplay;
159 use jacquard::from_data;
160 use weaver_api::sh_weaver::notebook::book::Book;
161
162 let auth_state = use_context::<Signal<AuthState>>();
163
164 // Use client-only versions to avoid SSR issues with concurrent server futures
165 let (_profile_res, profile) = data::use_profile_data(ident);
166 let (_notebooks_res, notebooks) = data::use_notebooks_for_did(ident);
167 let (_entries_res, all_entries) = data::use_entries_for_did(ident);
168
169 #[cfg(feature = "fullstack-server")]
170 {
171 _profile_res?;
172 _notebooks_res?;
173 _entries_res?;
174 }
175
176 // Check if viewing own profile
177 let is_own_profile = use_memo(move || {
178 let current_did = auth_state.read().did.clone();
179 match (¤t_did, ident()) {
180 (Some(did), AtIdentifier::Did(profile_did)) => *did == profile_did,
181 _ => false,
182 }
183 });
184
185 // Extract pinned URIs from profile (only Weaver ProfileView has pinned)
186 // Returns (Vec for ordering, HashSet for O(1) lookups)
187 let pinned_uris = use_memo(move || {
188 use jacquard::IntoStatic;
189 use weaver_api::sh_weaver::actor::ProfileDataViewInner;
190
191 let Some(prof) = profile.read().as_ref().cloned() else {
192 return (Vec::<String>::new(), HashSet::<String>::new());
193 };
194
195 match &prof.inner {
196 ProfileDataViewInner::ProfileView(p) => {
197 let uris: Vec<String> = p
198 .pinned
199 .as_ref()
200 .map(|pins| pins.iter().map(|r| r.uri.as_ref().to_string()).collect())
201 .unwrap_or_default();
202 let set: HashSet<String> = uris.iter().cloned().collect();
203 (uris, set)
204 }
205 _ => (Vec::new(), HashSet::new()),
206 }
207 });
208
209 // Compute standalone entries (entries not in any notebook)
210 let standalone_entries = use_memo(move || {
211 let nbs = notebooks.read();
212 let ents = all_entries.read();
213
214 let (Some(nbs), Some(ents)) = (nbs.as_ref(), ents.as_ref()) else {
215 return Vec::new();
216 };
217
218 // Collect all entry URIs from all notebook entry_lists
219 let notebook_entry_uris: HashSet<&str> = nbs
220 .iter()
221 .flat_map(|(_, refs)| refs.iter().map(|r| r.uri.as_ref()))
222 .collect();
223
224 // Filter entries not in any notebook
225 ents.iter()
226 .filter(|(view, _)| !notebook_entry_uris.contains(view.uri.as_ref()))
227 .cloned()
228 .collect::<Vec<_>>()
229 });
230
231 // Helper to check if a URI is pinned
232 fn is_pinned(uri: &str, pinned_set: &HashSet<String>) -> bool {
233 pinned_set.contains(uri)
234 }
235
236 // Build pinned items (matching notebooks/entries against pinned URIs)
237 let pinned_items = use_memo(move || {
238 let nbs = notebooks.read();
239 let standalone = standalone_entries.read();
240 let ents = all_entries.read();
241 let (pinned_vec, pinned_set) = &*pinned_uris.read();
242
243 let mut items: Vec<ProfileTimelineItem> = Vec::new();
244
245 // Check notebooks
246 if let Some(nbs) = nbs.as_ref() {
247 if let Some(all_ents) = ents.as_ref() {
248 for (notebook, entry_refs) in nbs {
249 if is_pinned(notebook.uri.as_ref(), pinned_set) {
250 let book_entries = build_book_entry_views(entry_refs, all_ents);
251 let sort_date = book_entries
252 .iter()
253 .filter_map(|bev| {
254 all_ents
255 .iter()
256 .find(|(v, _)| v.uri.as_ref() == bev.entry.uri.as_ref())
257 })
258 .map(|(_, entry)| entry.created_at.clone())
259 .max()
260 .unwrap_or_else(|| notebook.indexed_at.clone());
261
262 items.push(ProfileTimelineItem::Notebook {
263 notebook: notebook.clone(),
264 entries: book_entries,
265 sort_date,
266 });
267 }
268 }
269 }
270 }
271
272 // Check standalone entries
273 for (view, entry) in standalone.iter() {
274 if is_pinned(view.uri.as_ref(), pinned_set) {
275 items.push(ProfileTimelineItem::StandaloneEntry {
276 entry_view: view.clone(),
277 entry: entry.clone(),
278 });
279 }
280 }
281
282 // Sort pinned by their order in the pinned list
283 items.sort_by_key(|item| {
284 let uri = match item {
285 ProfileTimelineItem::Notebook { notebook, .. } => notebook.uri.as_ref(),
286 ProfileTimelineItem::StandaloneEntry { entry_view, .. } => entry_view.uri.as_ref(),
287 };
288 pinned_vec
289 .iter()
290 .position(|p| p == uri)
291 .unwrap_or(usize::MAX)
292 });
293
294 items
295 });
296
297 // Build merged timeline sorted by date (newest first), excluding pinned items
298 let timeline = use_memo(move || {
299 let nbs = notebooks.read();
300 let standalone = standalone_entries.read();
301 let ents = all_entries.read();
302 let (_pinned_vec, pinned_set) = &*pinned_uris.read();
303
304 let mut items: Vec<ProfileTimelineItem> = Vec::new();
305
306 // Add notebooks (excluding pinned)
307 if let Some(nbs) = nbs.as_ref() {
308 if let Some(all_ents) = ents.as_ref() {
309 for (notebook, entry_refs) in nbs {
310 if !is_pinned(notebook.uri.as_ref(), pinned_set) {
311 let book_entries = build_book_entry_views(entry_refs, all_ents);
312 let sort_date = book_entries
313 .iter()
314 .filter_map(|bev| {
315 all_ents
316 .iter()
317 .find(|(v, _)| v.uri.as_ref() == bev.entry.uri.as_ref())
318 })
319 .map(|(_, entry)| entry.created_at.clone())
320 .max()
321 .unwrap_or_else(|| notebook.indexed_at.clone());
322
323 items.push(ProfileTimelineItem::Notebook {
324 notebook: notebook.clone(),
325 entries: book_entries,
326 sort_date,
327 });
328 }
329 }
330 }
331 }
332
333 // Add standalone entries (excluding pinned)
334 for (view, entry) in standalone.iter() {
335 if !is_pinned(view.uri.as_ref(), pinned_set) {
336 items.push(ProfileTimelineItem::StandaloneEntry {
337 entry_view: view.clone(),
338 entry: entry.clone(),
339 });
340 }
341 }
342
343 // Sort by date descending (newest first)
344 items.sort_by(|a, b| b.sort_date().cmp(&a.sort_date()));
345
346 items
347 });
348
349 // Count standalone entries for stats
350 let entry_count = use_memo(move || all_entries.read().as_ref().map(|e| e.len()).unwrap_or(0));
351
352 // Build OG metadata when profile is available
353 let og_meta = match &*profile.read() {
354 Some(profile_view) => {
355 use weaver_api::sh_weaver::actor::ProfileDataViewInner;
356
357 let (display_name, handle, bio) = match &profile_view.inner {
358 ProfileDataViewInner::ProfileView(p) => (
359 p.display_name
360 .as_ref()
361 .map(|n| n.as_ref().to_string())
362 .unwrap_or_default(),
363 p.handle.as_ref().to_string(),
364 p.description
365 .as_ref()
366 .map(|d| d.as_ref().to_string())
367 .unwrap_or_default(),
368 ),
369 ProfileDataViewInner::ProfileViewDetailed(p) => (
370 p.display_name
371 .as_ref()
372 .map(|n| n.as_ref().to_string())
373 .unwrap_or_default(),
374 p.handle.as_ref().to_string(),
375 p.description
376 .as_ref()
377 .map(|d| d.as_ref().to_string())
378 .unwrap_or_default(),
379 ),
380 ProfileDataViewInner::TangledProfileView(p) => {
381 (String::new(), p.handle.as_ref().to_string(), String::new())
382 }
383 _ => (String::new(), "unknown".to_string(), String::new()),
384 };
385
386 let notebook_count = notebooks.read().as_ref().map(|n| n.len()).unwrap_or(0);
387
388 let base = if crate::env::WEAVER_APP_ENV == "dev" {
389 format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT)
390 } else {
391 crate::env::WEAVER_APP_HOST.to_string()
392 };
393 let og_image_url = format!("{}/og/profile/{}.png", base, ident());
394 let canonical_url = format!("{}/{}", base, ident());
395
396 Some(rsx! {
397 ProfileOgMeta {
398 display_name: if display_name.is_empty() { handle.clone() } else { display_name },
399 handle,
400 bio,
401 image_url: og_image_url,
402 canonical_url,
403 notebook_count,
404 }
405 })
406 }
407 None => None,
408 };
409
410 rsx! {
411 {og_meta}
412
413 div { class: "repository-layout",
414 // Profile sidebar (desktop) / header (mobile)
415 aside { class: "repository-sidebar",
416 ProfileDisplay { profile, notebooks, entry_count: *entry_count.read(), is_own_profile: is_own_profile() }
417 }
418
419 // Main content area
420 main { class: "repository-main",
421 // Mobile menubar (hidden on desktop)
422 ProfileActionsMenubar { ident }
423
424 div { class: "profile-timeline",
425 // Pinned items section
426 {
427 let pinned = pinned_items.read();
428 if !pinned.is_empty() {
429 rsx! {
430 div { class: "pinned-section",
431 h3 { class: "pinned-header", "Pinned" }
432 for (idx, item) in pinned.iter().enumerate() {
433 {
434 match item {
435 ProfileTimelineItem::Notebook { notebook, entries, .. } => {
436 rsx! {
437 div {
438 key: "pinned-notebook-{notebook.cid}",
439 class: "pinned-item",
440 NotebookCard {
441 notebook: notebook.clone(),
442 entries: entries.clone(),
443 is_pinned: true,
444 profile_ident: Some(ident()),
445 }
446 }
447 }
448 }
449 ProfileTimelineItem::StandaloneEntry { entry_view, entry } => {
450 rsx! {
451 div {
452 key: "pinned-entry-{idx}",
453 class: "pinned-item standalone-entry-item",
454 FeedEntryCard {
455 entry_view: entry_view.clone(),
456 entry: entry.clone(),
457 show_actions: true,
458 is_pinned: true,
459 profile_ident: Some(ident()),
460 }
461 }
462 }
463 }
464 }
465 }
466 }
467 }
468 }
469 } else {
470 rsx! {}
471 }
472 }
473
474 // Chronological timeline
475 {
476 let timeline_items = timeline.read();
477 if timeline_items.is_empty() && pinned_items.read().is_empty() {
478 rsx! { div { class: "timeline-empty", "No content yet" } }
479 } else {
480 rsx! {
481 for (idx, item) in timeline_items.iter().enumerate() {
482 {
483 match item {
484 ProfileTimelineItem::Notebook { notebook, entries, .. } => {
485 rsx! {
486 div {
487 key: "notebook-{notebook.cid}",
488 NotebookCard {
489 notebook: notebook.clone(),
490 entries: entries.clone(),
491 is_pinned: false,
492 profile_ident: Some(ident()),
493 }
494 }
495 }
496 }
497 ProfileTimelineItem::StandaloneEntry { entry_view, entry } => {
498 rsx! {
499 div {
500 key: "entry-{idx}",
501 class: "standalone-entry-item",
502 FeedEntryCard {
503 entry_view: entry_view.clone(),
504 entry: entry.clone(),
505 show_actions: true,
506 is_pinned: false,
507 profile_ident: Some(ident()),
508 }
509 }
510 }
511 }
512 }
513 }
514 }
515 }
516 }
517 }
518 }
519 }
520
521 // Actions sidebar (desktop only)
522 ProfileActions { ident }
523 }
524 }
525}
526
527#[component]
528fn NotebookEntryPreview(
529 book_entry_view: weaver_api::sh_weaver::notebook::BookEntryView<'static>,
530 ident: AtIdentifier<'static>,
531 book_title: SmolStr,
532 #[props(default)] extra_class: Option<&'static str>,
533) -> Element {
534 use jacquard::{IntoStatic, from_data};
535 use weaver_api::sh_weaver::notebook::entry::Entry;
536
537 let entry_view = &book_entry_view.entry;
538
539 let entry_title = entry_view
540 .title
541 .as_ref()
542 .map(|t| t.as_ref())
543 .unwrap_or("Untitled");
544
545 let entry_path = entry_view
546 .path
547 .as_ref()
548 .map(|p| p.as_ref().to_string())
549 .unwrap_or_else(|| entry_title.to_string());
550
551 let parsed_entry = from_data::<Entry>(&entry_view.record).ok();
552
553 let preview_html = parsed_entry.as_ref().map(|entry| {
554 let parser = markdown_weaver::Parser::new(&entry.content);
555 let mut html_buf = String::new();
556 markdown_weaver::html::push_html(&mut html_buf, parser);
557 html_buf
558 });
559
560 let created_at = parsed_entry
561 .as_ref()
562 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
563
564 let entry_uri = entry_view.uri.clone().into_static();
565
566 let class_name = if let Some(extra) = extra_class {
567 format!("notebook-entry-preview {}", extra)
568 } else {
569 "notebook-entry-preview".to_string()
570 };
571
572 rsx! {
573 div { class: "{class_name}",
574 div { class: "entry-preview-header",
575 Link {
576 to: Route::EntryPage {
577 ident: ident.clone(),
578 book_title: book_title.clone(),
579 title: entry_path.clone().into()
580 },
581 class: "entry-preview-title-link",
582 div { class: "entry-preview-title", "{entry_title}" }
583 }
584 if let Some(ref date) = created_at {
585 div { class: "entry-preview-date", "{date}" }
586 }
587 crate::components::EntryActions {
588 entry_uri,
589 entry_cid: entry_view.cid.clone().into_static(),
590 entry_title: entry_title.to_string(),
591 in_notebook: true,
592 notebook_title: Some(book_title.clone()),
593 permissions: entry_view.permissions.clone()
594 }
595 }
596 if let Some(ref html) = preview_html {
597 Link {
598 to: Route::EntryPage {
599 ident: ident.clone(),
600 book_title: book_title.clone(),
601 title: entry_path.clone().into()
602 },
603 class: "entry-preview-content-link",
604 div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
605 }
606 }
607 }
608 }
609}
610
611#[component]
612pub fn NotebookCard(
613 notebook: NotebookView<'static>,
614 entries: Vec<BookEntryView<'static>>,
615 #[props(default = false)] is_pinned: bool,
616 #[props(default)] show_author: Option<bool>,
617 /// Profile identity for context-aware author visibility (hides single author on their own profile)
618 #[props(default)]
619 profile_ident: Option<AtIdentifier<'static>>,
620 #[props(default)] on_pinned_changed: Option<EventHandler<bool>>,
621 #[props(default)] on_deleted: Option<EventHandler<()>>,
622) -> Element {
623 use jacquard::{IntoStatic, from_data};
624 use weaver_api::sh_weaver::notebook::book::Book;
625
626 let fetcher = use_context::<fetch::Fetcher>();
627 let auth_state = use_context::<Signal<AuthState>>();
628
629 // Parse Book from the NotebookView's record field for settings.
630 let book: Option<Book<'static>> = from_data(¬ebook.record)
631 .ok()
632 .map(|b: Book<'_>| b.into_static());
633
634 let title = notebook
635 .title
636 .as_ref()
637 .map(|t| t.as_ref())
638 .unwrap_or("Untitled Notebook");
639
640 // Get notebook path for URLs, fallback to title
641 let notebook_path = notebook
642 .path
643 .as_ref()
644 .map(|p| p.as_ref().to_string())
645 .unwrap_or_else(|| title.to_string());
646
647 // Check ownership for "Add Entry" link
648 let notebook_ident = notebook.uri.authority().clone().into_static();
649 let is_owner = {
650 let current_did = auth_state.read().did.clone();
651 match (¤t_did, ¬ebook_ident) {
652 (Some(did), AtIdentifier::Did(nb_did)) => *did == *nb_did,
653 _ => false,
654 }
655 };
656
657 // Format date
658 let formatted_date = notebook.indexed_at.as_ref().format("%B %d, %Y").to_string();
659
660 // Show authors: explicit prop overrides, otherwise show only if multiple
661 let show_authors = show_author.unwrap_or(notebook.authors.len() > 1);
662
663 let ident = notebook.uri.authority().clone().into_static();
664 let book_title: SmolStr = notebook_path.clone().into();
665
666 rsx! {
667 div { class: "notebook-card",
668 div { class: "notebook-card-container",
669
670 div { class: "notebook-card-header",
671 div { class: "notebook-card-header-top",
672 Link {
673 to: Route::EntryPage {
674 ident: ident.clone(),
675 book_title: notebook_path.clone().into(),
676 title: "".into() // Will redirect to first entry
677 },
678 class: "notebook-card-header-link",
679 h2 { class: "notebook-card-title", "{title}" }
680 }
681 if is_owner {
682 div { class: "notebook-header-actions",
683 Link {
684 to: Route::NewDraft { ident: notebook_ident.clone(), notebook: Some(book_title.clone()) },
685 class: "notebook-action-link",
686 crate::components::button::Button {
687 variant: crate::components::button::ButtonVariant::Ghost,
688 "Add"
689 }
690 }
691 if let Some(ref book) = book {
692 crate::components::NotebookActions {
693 notebook_uri: notebook.uri.clone().into_static(),
694 notebook_cid: notebook.cid.clone().into_static(),
695 notebook_title: title.to_string(),
696 notebook: book.clone(),
697 is_pinned,
698 on_pinned_changed,
699 on_deleted
700 }
701 }
702 }
703 }
704 }
705
706 div { class: "notebook-card-date",
707 time { datetime: "{notebook.indexed_at.as_str()}", "{formatted_date}" }
708 }
709 }
710
711 // Show authors
712 if show_authors {
713 div { class: "notebook-card-authors",
714 AuthorList {
715 authors: notebook.authors.clone(),
716 profile_ident: profile_ident.clone(),
717 owner_ident: Some(ident.clone()),
718 }
719 }
720 }
721
722 // Entry previews section
723 div { class: "notebook-card-previews",
724 {
725 use jacquard::from_data;
726 use weaver_api::sh_weaver::notebook::entry::Entry;
727 tracing::info!("rendering entries: {:?}", entries.iter().map(|e|
728 e.entry.uri.as_ref()).collect::<Vec<_>>());
729
730 if entries.len() <= 5 {
731 // Show all entries if 5 or fewer
732 rsx! {
733 for entry_view in entries.iter() {
734 NotebookEntryPreview {
735 book_entry_view: entry_view.clone(),
736 ident: ident.clone(),
737 book_title: book_title.clone(),
738 }
739 }
740 }
741 } else {
742 // Show first, interstitial, and last
743 rsx! {
744 if let Some(first_entry) = entries.first() {
745 NotebookEntryPreview {
746 book_entry_view: first_entry.clone(),
747 ident: ident.clone(),
748 book_title: book_title.clone(),
749 extra_class: "notebook-entry-preview-first",
750 }
751 }
752
753 // Interstitial showing count
754 {
755 let middle_count = entries.len().saturating_sub(2);
756 rsx! {
757 div { class: "notebook-entry-interstitial",
758 "... {middle_count} more "
759 if middle_count == 1 { "entry" } else { "entries" }
760 " ..."
761 }
762 }
763 }
764
765 if let Some(last_entry) = entries.last() {
766 NotebookEntryPreview {
767 book_entry_view: last_entry.clone(),
768 ident: ident.clone(),
769 book_title: book_title.clone(),
770 extra_class: "notebook-entry-preview-last",
771 }
772 }
773 }
774 }
775 }
776 }
777
778
779 if let Some(ref tags) = notebook.tags {
780 if !tags.is_empty() {
781 div { class: "notebook-card-tags",
782 for tag in tags.iter() {
783 span { class: "notebook-card-tag", "{tag}" }
784 }
785 }
786 }
787 }
788 }
789 }
790 }
791}