atproto blogging
1use crate::{
2 Route,
3 auth::AuthState,
4 components::{EntryCard, NotebookCover, NotebookCss},
5 components::button::{Button, ButtonVariant},
6 data,
7};
8use dioxus::prelude::*;
9use jacquard::{
10 smol_str::{SmolStr, ToSmolStr, format_smolstr},
11 types::ident::AtIdentifier,
12};
13
14/// OpenGraph and Twitter Card meta tags for notebook index pages
15#[component]
16pub fn NotebookOgMeta(
17 title: String,
18 description: String,
19 image_url: String,
20 canonical_url: String,
21 author_handle: String,
22 entry_count: usize,
23) -> Element {
24 let page_title = format!("{} | @{} | Weaver", title, author_handle);
25 let full_description = if entry_count > 0 {
26 format!("{} entries · {}", entry_count, description)
27 } else {
28 description.clone()
29 };
30
31 rsx! {
32 document::Title { "{page_title}" }
33 document::Meta { property: "og:title", content: "{title}" }
34 document::Meta { property: "og:description", content: "{full_description}" }
35 document::Meta { property: "og:image", content: "{image_url}" }
36 document::Meta { property: "og:type", content: "website" }
37 document::Meta { property: "og:url", content: "{canonical_url}" }
38 document::Meta { property: "og:site_name", content: "Weaver" }
39 document::Meta { name: "twitter:card", content: "summary_large_image" }
40 document::Meta { name: "twitter:title", content: "{title}" }
41 document::Meta { name: "twitter:description", content: "{full_description}" }
42 document::Meta { name: "twitter:image", content: "{image_url}" }
43 document::Meta { name: "twitter:creator", content: "@{author_handle}" }
44 }
45}
46
47// Card styles loaded at navbar level
48const LAYOUTS_CSS: Asset = asset!("/assets/styling/layouts.css");
49
50/// The Blog page component that will be rendered when the current route is `[Route::Blog]`
51///
52/// The component takes a `id` prop of type `i32` from the route enum. Whenever the id changes, the component function will be
53/// re-run and the rendered HTML will be updated.
54#[component]
55pub fn Notebook(ident: ReadSignal<AtIdentifier<'static>>, book_title: SmolStr) -> Element {
56 tracing::debug!(
57 "Notebook component rendering for ident: {:?}, book: {}",
58 ident(),
59 book_title
60 );
61 rsx! {
62 NotebookCss { ident: ident.to_smolstr(), notebook: book_title }
63 Outlet::<Route> {}
64 }
65}
66
67#[component]
68pub fn NotebookIndex(
69 ident: ReadSignal<AtIdentifier<'static>>,
70 book_title: ReadSignal<SmolStr>,
71) -> Element {
72 tracing::info!(
73 "NotebookIndex: start, ident={:?}, book={}",
74 ident(),
75 book_title()
76 );
77 // Fetch full notebook metadata with SSR support
78 // IMPORTANT: Call ALL hooks before any ? early returns to maintain hook order
79 let (notebook_result, notebook_data) = data::use_notebook(ident, book_title);
80 tracing::info!("NotebookIndex: use_notebook returned");
81 let (entries_result, entries_resource) = data::use_notebook_entries(ident, book_title);
82 tracing::info!("NotebookIndex: use_notebook_entries returned");
83
84 #[cfg(feature = "fullstack-server")]
85 notebook_result?;
86 tracing::info!("NotebookIndex: past notebook_result?");
87
88 #[cfg(feature = "fullstack-server")]
89 entries_result?;
90 tracing::info!("NotebookIndex: past entries_result?");
91
92 // Check ownership for "Add Entry" button
93 let auth_state = use_context::<Signal<AuthState>>();
94 let is_owner = {
95 let current_did = auth_state.read().did.clone();
96 match (¤t_did, ident()) {
97 (Some(did), AtIdentifier::Did(ref ident_did)) => *did == *ident_did,
98 _ => false,
99 }
100 };
101
102 rsx! {
103 document::Link { rel: "stylesheet", href: LAYOUTS_CSS }
104
105 match (&*notebook_data.read(), &*entries_resource.read()) {
106 (Some(data), Some(entries)) => {
107 let (notebook_view, _) = data;
108 let author_count = notebook_view.authors.len();
109
110 // Build OG metadata
111 let og_title = notebook_view.title
112 .as_ref()
113 .map(|t| t.as_ref().to_string())
114 .unwrap_or_else(|| "Untitled Notebook".to_string());
115
116 let og_author = {
117 use weaver_api::sh_weaver::actor::ProfileDataViewInner;
118 notebook_view.authors.first()
119 .map(|a| match &a.record.inner {
120 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_smolstr(),
121 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_smolstr(),
122 ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_smolstr(),
123 _ => "unknown".into(),
124 })
125 .unwrap_or_else(|| "unknown".into())
126 };
127
128 // NotebookView doesn't expose description directly, use empty for now
129 let og_description = String::new();
130
131 let base = if crate::env::WEAVER_APP_ENV == "dev" {
132 format_smolstr!("http://127.0.0.1:{}", crate::env::WEAVER_PORT)
133 } else {
134 SmolStr::new_static(crate::env::WEAVER_APP_HOST)
135 };
136 let og_image_url = format_smolstr!("{}/og/notebook/{}/{}.png", base, ident(), book_title());
137 let canonical_url = format_smolstr!("{}/{}/{}", base, ident(), book_title());
138
139 rsx! {
140 NotebookOgMeta {
141 title: og_title,
142 description: og_description,
143 image_url: og_image_url.to_string(),
144 canonical_url: canonical_url.to_string(),
145 author_handle: og_author.to_string(),
146 entry_count: entries.len(),
147 }
148 div { class: "notebook-layout",
149 aside { class: "notebook-sidebar",
150 NotebookCover {
151 notebook: notebook_view.clone(),
152 title: book_title().to_string(),
153 is_owner,
154 ident: Some(ident())
155 }
156 }
157
158 main { class: "notebook-main",
159 div { class: "entries-list",
160 for entry in entries {
161 EntryCard {
162 entry: entry.clone(),
163 book_title: book_title(),
164 author_count,
165 ident: ident(),
166 }
167 }
168 }
169 }
170 }
171 }
172 },
173 _ => rsx! { div { class: "loading", "Loading..." } }
174 }
175 }
176}