···32use weaver_api::app_bsky::actor::get_profile::GetProfile;
33use weaver_api::app_bsky::actor::profile::Profile as BskyProfile;
34use weaver_api::sh_weaver::actor::ProfileDataViewInner;
35+use weaver_api::sh_weaver::notebook::EntryView;
36use weaver_api::{
37 com_atproto::repo::strong_ref::StrongRef,
38 sh_weaver::{
···50 record: serde_json::Value,
51 rkey: String,
52 time_us: u64,
53+}
54+55+/// Data for a standalone entry (may or may not have notebook context)
56+#[derive(Clone, PartialEq)]
57+pub struct StandaloneEntryData {
58+ pub entry: Entry<'static>,
59+ pub entry_view: EntryView<'static>,
60+ /// Present if entry is in exactly one notebook
61+ pub notebook_context: Option<NotebookContext>,
62+}
63+64+/// Notebook context for an entry
65+#[derive(Clone, PartialEq)]
66+pub struct NotebookContext {
67+ pub notebook: NotebookView<'static>,
68+ /// BookEntryView with prev/next navigation
69+ pub book_entry_view: BookEntryView<'static>,
70}
7172pub struct Client {
···583 .map_err(|e| dioxus::CapturedError::from_display(e))?;
584585 Ok(Arc::new(profile_view))
586+ }
587+588+ /// Fetch an entry by rkey with optional notebook context lookup.
589+ pub async fn get_entry_by_rkey(
590+ &self,
591+ ident: AtIdentifier<'static>,
592+ rkey: SmolStr,
593+ ) -> Result<Option<Arc<StandaloneEntryData>>> {
594+ use jacquard::types::aturi::AtUri;
595+596+ let client = self.get_client();
597+598+ // Fetch entry directly by rkey
599+ let (entry_view, entry) = client
600+ .fetch_entry_by_rkey(&ident, &rkey)
601+ .await
602+ .map_err(|e| dioxus::CapturedError::from_display(e))?;
603+604+ // Try to find notebook context via constellation
605+ let entry_uri = entry_view.uri.clone();
606+ let at_uri = AtUri::new(entry_uri.as_ref()).map_err(|e| {
607+ dioxus::CapturedError::from_display(format!("Invalid entry URI: {}", e))
608+ })?;
609+610+ let (total, first_notebook) = client
611+ .find_notebooks_for_entry(&at_uri)
612+ .await
613+ .map_err(|e| dioxus::CapturedError::from_display(e))?;
614+615+ // Only provide notebook context if entry is in exactly one notebook
616+ let notebook_context = if total == 1 {
617+ if let Some(notebook_id) = first_notebook {
618+ // Construct notebook URI from RecordId
619+ let notebook_uri_str = format!(
620+ "at://{}/{}/{}",
621+ notebook_id.did.as_str(),
622+ notebook_id.collection.as_str(),
623+ notebook_id.rkey.0.as_str()
624+ );
625+ let notebook_uri = AtUri::new_owned(notebook_uri_str).map_err(|e| {
626+ dioxus::CapturedError::from_display(format!("Invalid notebook URI: {}", e))
627+ })?;
628+629+ // Fetch notebook and find entry position
630+ if let Ok((notebook, entries)) = client.view_notebook(¬ebook_uri).await {
631+ if let Ok(Some(book_entry_view)) = client
632+ .entry_in_notebook_by_rkey(¬ebook, &entries, &rkey)
633+ .await
634+ {
635+ Some(NotebookContext {
636+ notebook: notebook.into_static(),
637+ book_entry_view: book_entry_view.into_static(),
638+ })
639+ } else {
640+ None
641+ }
642+ } else {
643+ None
644+ }
645+ } else {
646+ None
647+ }
648+ } else {
649+ None
650+ };
651+652+ Ok(Some(Arc::new(StandaloneEntryData {
653+ entry,
654+ entry_view,
655+ notebook_context,
656+ })))
657+ }
658+659+ /// Fetch an entry by rkey within a specific notebook context.
660+ ///
661+ /// The book_title parameter provides the notebook context.
662+ /// Returns BookEntryView without prev/next if entry is in multiple notebooks.
663+ pub async fn get_notebook_entry_by_rkey(
664+ &self,
665+ ident: AtIdentifier<'static>,
666+ book_title: SmolStr,
667+ rkey: SmolStr,
668+ ) -> Result<Option<Arc<(BookEntryView<'static>, Entry<'static>)>>> {
669+ use jacquard::types::aturi::AtUri;
670+671+ let client = self.get_client();
672+673+ // Fetch entry directly by rkey
674+ let (entry_view, entry) = client
675+ .fetch_entry_by_rkey(&ident, &rkey)
676+ .await
677+ .map_err(|e| dioxus::CapturedError::from_display(e))?;
678+679+ // Fetch notebook by title
680+ let notebook_result = client
681+ .notebook_by_title(&ident, &book_title)
682+ .await
683+ .map_err(|e| dioxus::CapturedError::from_display(e))?;
684+685+ let (notebook, entries) = match notebook_result {
686+ Some((n, e)) => (n, e),
687+ None => return Err(dioxus::CapturedError::from_display("Notebook not found")),
688+ };
689+690+ // Find entry position in notebook
691+ let book_entry_view = client
692+ .entry_in_notebook_by_rkey(¬ebook, &entries, &rkey)
693+ .await
694+ .map_err(|e| dioxus::CapturedError::from_display(e))?;
695+696+ let mut book_entry_view = match book_entry_view {
697+ Some(bev) => bev,
698+ None => {
699+ // Entry not in this notebook's entry list - return basic view without nav
700+ use weaver_api::sh_weaver::notebook::BookEntryView;
701+ BookEntryView::new().entry(entry_view).index(0).build()
702+ }
703+ };
704+705+ // Check if entry is in multiple notebooks - if so, clear prev/next
706+ let entry_uri = book_entry_view.entry.uri.clone();
707+ let at_uri = AtUri::new(entry_uri.as_ref()).map_err(|e| {
708+ dioxus::CapturedError::from_display(format!("Invalid entry URI: {}", e))
709+ })?;
710+711+ let (total, _) = client
712+ .find_notebooks_for_entry(&at_uri)
713+ .await
714+ .map_err(|e| dioxus::CapturedError::from_display(e))?;
715+716+ if total >= 2 {
717+ // Entry is in multiple notebooks - clear prev/next to avoid ambiguity
718+ book_entry_view = BookEntryView::new()
719+ .entry(book_entry_view.entry)
720+ .index(book_entry_view.index)
721+ .build();
722+ }
723+724+ Ok(Some(Arc::new((book_entry_view.into_static(), entry))))
725 }
726}
727
+301
crates/weaver-app/src/main.rs
···587 ).into_response())
588}
5890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000590// #[server(endpoint = "static_routes", output = server_fn::codec::Json)]
591// async fn static_routes() -> Result<Vec<String>, ServerFnError> {
592// // The `Routable` trait has a `static_routes` method that returns all static routes in the enum
···587 ).into_response())
588}
589590+// Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index
591+#[cfg(all(feature = "fullstack-server", feature = "server"))]
592+#[get("/og/notebook/{ident}/{book_title}", fetcher: Extension<Arc<fetch::Fetcher>>)]
593+pub async fn og_notebook_image(
594+ ident: SmolStr,
595+ book_title: SmolStr,
596+) -> Result<axum::response::Response> {
597+ use axum::{
598+ http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode},
599+ response::IntoResponse,
600+ };
601+ use weaver_api::sh_weaver::actor::ProfileDataViewInner;
602+603+ // Strip .png extension if present
604+ let book_title = book_title.strip_suffix(".png").unwrap_or(&book_title);
605+606+ let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else {
607+ return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response());
608+ };
609+610+ // Fetch notebook data
611+ let notebook_result = fetcher.get_notebook(at_ident.clone(), book_title.into()).await;
612+613+ let arc_data = match notebook_result {
614+ Ok(Some(data)) => data,
615+ Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()),
616+ Err(e) => {
617+ tracing::error!("Failed to fetch notebook for OG image: {:?}", e);
618+ return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch notebook").into_response());
619+ }
620+ };
621+ let (notebook_view, _entries) = arc_data.as_ref();
622+623+ // Build cache key using notebook CID
624+ let notebook_cid = notebook_view.cid.as_ref();
625+ let cache_key = og::notebook_cache_key(&ident, book_title, notebook_cid);
626+627+ // Check cache first
628+ if let Some(cached) = og::get_cached(&cache_key) {
629+ return Ok((
630+ [
631+ (CONTENT_TYPE, "image/png"),
632+ (CACHE_CONTROL, "public, max-age=3600"),
633+ ],
634+ cached,
635+ ).into_response());
636+ }
637+638+ // Extract metadata
639+ let title = notebook_view.title
640+ .as_ref()
641+ .map(|t| t.as_ref())
642+ .unwrap_or("Untitled Notebook");
643+644+ let author_handle = notebook_view.authors.first()
645+ .map(|a| match &a.record.inner {
646+ ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(),
647+ ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(),
648+ ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(),
649+ _ => "unknown".to_string(),
650+ })
651+ .unwrap_or_else(|| "unknown".to_string());
652+653+ // Fetch entries to get entry titles and count
654+ let entries_result = fetcher.list_notebook_entries(at_ident.clone(), book_title.into()).await;
655+ let (entry_count, entry_titles) = match entries_result {
656+ Ok(Some(entries)) => {
657+ let count = entries.len();
658+ let titles: Vec<String> = entries
659+ .iter()
660+ .take(4)
661+ .map(|e| {
662+ e.entry.title
663+ .as_ref()
664+ .map(|t| t.as_ref().to_string())
665+ .unwrap_or_else(|| "Untitled".to_string())
666+ })
667+ .collect();
668+ (count, titles)
669+ }
670+ _ => (0, vec![]),
671+ };
672+673+ // Generate image
674+ let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles) {
675+ Ok(bytes) => bytes,
676+ Err(e) => {
677+ tracing::error!("Failed to generate notebook OG image: {:?}", e);
678+ return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response());
679+ }
680+ };
681+682+ // Cache the generated image
683+ og::cache_image(cache_key, png_bytes.clone());
684+685+ Ok((
686+ [
687+ (CONTENT_TYPE, "image/png"),
688+ (CACHE_CONTROL, "public, max-age=3600"),
689+ ],
690+ png_bytes,
691+ ).into_response())
692+}
693+694+// Route: /og/profile/{ident}.png - OpenGraph image for profile/repository
695+#[cfg(all(feature = "fullstack-server", feature = "server"))]
696+#[get("/og/profile/{ident}", fetcher: Extension<Arc<fetch::Fetcher>>)]
697+pub async fn og_profile_image(
698+ ident: SmolStr,
699+) -> Result<axum::response::Response> {
700+ use axum::{
701+ http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode},
702+ response::IntoResponse,
703+ };
704+ use weaver_api::sh_weaver::actor::ProfileDataViewInner;
705+706+ // Strip .png extension if present
707+ let ident = ident.strip_suffix(".png").unwrap_or(&ident);
708+709+ let Ok(at_ident) = AtIdentifier::new_owned(ident.to_string()) else {
710+ return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response());
711+ };
712+713+ // Fetch profile data
714+ let profile_result = fetcher.fetch_profile(&at_ident).await;
715+716+ let profile_view = match profile_result {
717+ Ok(data) => data,
718+ Err(e) => {
719+ tracing::error!("Failed to fetch profile for OG image: {:?}", e);
720+ return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response());
721+ }
722+ };
723+724+ // Extract profile fields based on type
725+ // Use DID as cache key since profiles don't have a CID field
726+ let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner {
727+ ProfileDataViewInner::ProfileView(p) => (
728+ p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(),
729+ p.handle.as_ref().to_string(),
730+ p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(),
731+ p.avatar.as_ref().map(|u| u.as_ref().to_string()),
732+ None::<String>,
733+ p.did.as_ref().to_string(),
734+ ),
735+ ProfileDataViewInner::ProfileViewDetailed(p) => (
736+ p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(),
737+ p.handle.as_ref().to_string(),
738+ p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(),
739+ p.avatar.as_ref().map(|u| u.as_ref().to_string()),
740+ p.banner.as_ref().map(|u| u.as_ref().to_string()),
741+ p.did.as_ref().to_string(),
742+ ),
743+ ProfileDataViewInner::TangledProfileView(p) => (
744+ String::new(),
745+ p.handle.as_ref().to_string(),
746+ String::new(),
747+ None,
748+ None,
749+ p.did.as_ref().to_string(),
750+ ),
751+ _ => return Ok((StatusCode::NOT_FOUND, "Profile type not supported").into_response()),
752+ };
753+754+ // Build cache key
755+ let cache_key = og::profile_cache_key(ident, &cache_id);
756+757+ // Check cache first
758+ if let Some(cached) = og::get_cached(&cache_key) {
759+ return Ok((
760+ [
761+ (CONTENT_TYPE, "image/png"),
762+ (CACHE_CONTROL, "public, max-age=3600"),
763+ ],
764+ cached,
765+ ).into_response());
766+ }
767+768+ // Fetch notebook count
769+ let notebooks_result = fetcher.fetch_notebooks_for_did(&at_ident).await;
770+ let notebook_count = notebooks_result.map(|n| n.len()).unwrap_or(0);
771+772+ // Fetch avatar as base64 if available
773+ let avatar_data = if let Some(ref url) = avatar_url {
774+ match reqwest::get(url).await {
775+ Ok(response) if response.status().is_success() => {
776+ let content_type = response
777+ .headers()
778+ .get("content-type")
779+ .and_then(|v| v.to_str().ok())
780+ .unwrap_or("image/jpeg")
781+ .to_string();
782+ match response.bytes().await {
783+ Ok(bytes) => {
784+ use base64::Engine;
785+ let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes);
786+ Some(format!("data:{};base64,{}", content_type, base64_str))
787+ }
788+ Err(_) => None,
789+ }
790+ }
791+ _ => None,
792+ }
793+ } else {
794+ None
795+ };
796+797+ // Check for banner and generate appropriate template
798+ let png_bytes = if let Some(ref banner_url) = banner_url {
799+ // Fetch banner image
800+ let banner_data = match reqwest::get(banner_url).await {
801+ Ok(response) if response.status().is_success() => {
802+ let content_type = response
803+ .headers()
804+ .get("content-type")
805+ .and_then(|v| v.to_str().ok())
806+ .unwrap_or("image/jpeg")
807+ .to_string();
808+ match response.bytes().await {
809+ Ok(bytes) => {
810+ use base64::Engine;
811+ let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes);
812+ Some(format!("data:{};base64,{}", content_type, base64_str))
813+ }
814+ Err(_) => None,
815+ }
816+ }
817+ _ => None,
818+ };
819+820+ if let Some(banner_data) = banner_data {
821+ match og::generate_profile_banner_og(
822+ &display_name,
823+ &handle,
824+ &bio,
825+ banner_data,
826+ avatar_data.clone(),
827+ notebook_count,
828+ ) {
829+ Ok(bytes) => bytes,
830+ Err(e) => {
831+ tracing::error!("Failed to generate profile banner OG image: {:?}, falling back", e);
832+ og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count)
833+ .unwrap_or_default()
834+ }
835+ }
836+ } else {
837+ og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count)
838+ .unwrap_or_default()
839+ }
840+ } else {
841+ match og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) {
842+ Ok(bytes) => bytes,
843+ Err(e) => {
844+ tracing::error!("Failed to generate profile OG image: {:?}", e);
845+ return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response());
846+ }
847+ }
848+ };
849+850+ // Cache the generated image
851+ og::cache_image(cache_key, png_bytes.clone());
852+853+ Ok((
854+ [
855+ (CONTENT_TYPE, "image/png"),
856+ (CACHE_CONTROL, "public, max-age=3600"),
857+ ],
858+ png_bytes,
859+ ).into_response())
860+}
861+862+// Route: /og/site.png - OpenGraph image for homepage
863+#[cfg(all(feature = "fullstack-server", feature = "server"))]
864+#[get("/og/site.png")]
865+pub async fn og_site_image() -> Result<axum::response::Response> {
866+ use axum::{
867+ http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode},
868+ response::IntoResponse,
869+ };
870+871+ // Site OG is static, cache aggressively
872+ static SITE_OG_CACHE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
873+874+ let png_bytes = SITE_OG_CACHE.get_or_init(|| {
875+ og::generate_site_og().unwrap_or_default()
876+ });
877+878+ if png_bytes.is_empty() {
879+ return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response());
880+ }
881+882+ Ok((
883+ [
884+ (CONTENT_TYPE, "image/png"),
885+ (CACHE_CONTROL, "public, max-age=86400"),
886+ ],
887+ png_bytes.clone(),
888+ ).into_response())
889+}
890+891// #[server(endpoint = "static_routes", output = server_fn::codec::Json)]
892// async fn static_routes() -> Result<Vec<String>, ServerFnError> {
893// // The `Routable` trait has a `static_routes` method that returns all static routes in the enum
+152-1
crates/weaver-app/src/og/mod.rs
···89 pub author_handle: String,
90}
910000000000000000000000000000000000000092/// Global font database, initialized once
93static FONTDB: OnceLock<fontdb::Database> = OnceLock::new();
94···98 // Load IBM Plex Sans from embedded bytes
99 let font_data = include_bytes!("../../assets/fonts/IBMPlexSans-VariableFont_wdth,wght.ttf");
100 db.load_font_data(font_data.to_vec());
000101 let font_data =
102- include_bytes!("../../assets/fonts/ioskeley-mono/IoskeleyMono-Regular.woff2");
103 db.load_font_data(font_data.to_vec());
104 db
105 })
···175 notebook_title: notebook_title.to_string(),
176 author_handle: author_handle.to_string(),
177 };
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000178179 let svg = template
180 .render()
···2use dioxus::prelude::*;
3use jacquard::types::aturi::AtUri;
4000000000000000000000000000005const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css");
67/// The Home page component that will be rendered when the current route is `[Route::Home]`
···18 .transpose()?;
1920 rsx! {
021 document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS }
22 div {
23 class: "record-view-container",
···2use dioxus::prelude::*;
3use jacquard::types::aturi::AtUri;
45+/// OpenGraph and Twitter Card meta tags for the homepage
6+#[component]
7+pub fn SiteOgMeta() -> Element {
8+ let base = if crate::env::WEAVER_APP_ENV == "dev" {
9+ format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT)
10+ } else {
11+ crate::env::WEAVER_APP_HOST.to_string()
12+ };
13+14+ let title = "Weaver";
15+ let description = "Share your words, your way.";
16+ let image_url = format!("{}/og/site.png", base);
17+ let canonical_url = base;
18+19+ rsx! {
20+ document::Title { "{title}" }
21+ document::Meta { property: "og:title", content: "{title}" }
22+ document::Meta { property: "og:description", content: "{description}" }
23+ document::Meta { property: "og:image", content: "{image_url}" }
24+ document::Meta { property: "og:type", content: "website" }
25+ document::Meta { property: "og:url", content: "{canonical_url}" }
26+ document::Meta { property: "og:site_name", content: "Weaver" }
27+ document::Meta { name: "twitter:card", content: "summary_large_image" }
28+ document::Meta { name: "twitter:title", content: "{title}" }
29+ document::Meta { name: "twitter:description", content: "{description}" }
30+ document::Meta { name: "twitter:image", content: "{image_url}" }
31+ }
32+}
33+34const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css");
3536/// The Home page component that will be rendered when the current route is `[Route::Home]`
···47 .transpose()?;
4849 rsx! {
50+ SiteOgMeta {}
51 document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS }
52 div {
53 class: "record-view-container",
+4-4
crates/weaver-app/src/views/mod.rs
···30pub use editor::Editor;
3132mod drafts;
33-pub use drafts::{
34- DraftEdit, DraftsList, NewDraft, NotebookEntryByRkey, NotebookEntryEdit, StandaloneEntry,
35- StandaloneEntryEdit,
36-};
···30pub use editor::Editor;
3132mod drafts;
33+pub use drafts::{DraftEdit, DraftsList, NewDraft, NotebookEntryEdit, StandaloneEntryEdit};
34+35+mod entry;
36+pub use entry::{NotebookEntryByRkey, StandaloneEntry};
···11 types::ident::AtIdentifier,
12};
1300000000000000000000000000000000014const ENTRY_CARD_CSS: Asset = asset!("/assets/styling/entry-card.css");
1516/// The Blog page component that will be rendered when the current route is `[Route::Blog]`
···70 let (notebook_view, _) = data;
71 let author_count = notebook_view.authors.len();
720000000000000000000000000000073 rsx! {
0000000074 div { class: "notebook-layout",
75 aside { class: "notebook-sidebar",
76 NotebookCover {