diff --git a/crates/weaver-app/assets/fonts/IBMPlexSans-Bold.ttf b/crates/weaver-app/assets/fonts/IBMPlexSans-Bold.ttf new file mode 100644 index 0000000..0cd407a --- /dev/null +++ b/crates/weaver-app/assets/fonts/IBMPlexSans-Bold.ttf @@ -0,0 +1,1436 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/crates/weaver-app/src/components/css.rs b/crates/weaver-app/src/components/css.rs index 4533f71..525a18c 100644 --- a/crates/weaver-app/src/components/css.rs +++ b/crates/weaver-app/src/components/css.rs @@ -86,6 +86,30 @@ pub fn NotebookCss(ident: SmolStr, notebook: SmolStr) -> Element { } } +#[component] +pub fn DefaultNotebookCss() -> Element { + use weaver_renderer::css::{generate_base_css, generate_syntax_css}; + use weaver_renderer::theme::default_resolved_theme; + + let css_content = use_resource(move || async move { + let resolved_theme = default_resolved_theme(); + + let mut css = generate_base_css(&resolved_theme); + css.push_str( + &generate_syntax_css(&resolved_theme) + .await + .unwrap_or_default(), + ); + + Some(css) + }); + + match css_content() { + Some(Some(css)) => rsx! { document::Style { {css} } }, + _ => rsx! {}, + } +} + #[cfg(feature = "fullstack-server")] #[get("/css/{ident}/{notebook}", fetcher: Extension>)] pub async fn css(ident: SmolStr, notebook: SmolStr) -> Result { diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs index eef23d7..145b81c 100644 --- a/crates/weaver-app/src/components/entry.rs +++ b/crates/weaver-app/src/components/entry.rs @@ -12,7 +12,7 @@ use dioxus::prelude::*; use jacquard::IntoStatic; use jacquard::types::aturi::AtUri; -const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); +pub const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); #[allow(unused_imports)] use jacquard::smol_str::ToSmolStr; @@ -118,7 +118,7 @@ pub fn EntryPage( } /// Extract a plain-text preview from markdown content (first ~160 chars) -fn extract_preview(content: &str, max_len: usize) -> String { +pub fn extract_preview(content: &str, max_len: usize) -> String { // Simple extraction: skip markdown syntax, get plain text let plain: String = content .lines() @@ -149,6 +149,38 @@ fn extract_preview(content: &str, max_len: usize) -> String { } } +/// OpenGraph and Twitter Card meta tags for entries +#[component] +pub fn EntryOgMeta( + title: String, + description: String, + image_url: String, + canonical_url: String, + author_handle: String, + #[props(default)] book_title: Option, +) -> Element { + let page_title = if let Some(ref book) = book_title { + format!("{} | {} | Weaver", title, book) + } else { + format!("{} | Weaver", title) + }; + + rsx! { + document::Title { "{page_title}" } + document::Meta { property: "og:title", content: "{title}" } + document::Meta { property: "og:description", content: "{description}" } + document::Meta { property: "og:image", content: "{image_url}" } + document::Meta { property: "og:type", content: "article" } + document::Meta { property: "og:url", content: "{canonical_url}" } + document::Meta { property: "og:site_name", content: "Weaver" } + document::Meta { name: "twitter:card", content: "summary_large_image" } + document::Meta { name: "twitter:title", content: "{title}" } + document::Meta { name: "twitter:description", content: "{description}" } + document::Meta { name: "twitter:image", content: "{image_url}" } + document::Meta { name: "twitter:creator", content: "@{author_handle}" } + } +} + /// Full entry page with metadata, content, and navigation #[component] fn EntryPageView( @@ -211,29 +243,17 @@ fn EntryPageView( // Extract description preview from content let description = extract_preview(entry_record().content.as_ref(), 160); - // Full page title - let page_title = format!("{} | {} | Weaver", title, book_title()); - tracing::info!("Entry: {book_title} - {title}"); rsx! { - // Page title and OG meta tags - document::Title { "{page_title}" } - - // OpenGraph tags - document::Meta { property: "og:title", content: "{title}" } - document::Meta { property: "og:description", content: "{description}" } - document::Meta { property: "og:image", content: "{og_image_url}" } - document::Meta { property: "og:type", content: "article" } - document::Meta { property: "og:url", content: "{canonical_url}" } - document::Meta { property: "og:site_name", content: "Weaver" } - - // Twitter Card tags - document::Meta { name: "twitter:card", content: "summary_large_image" } - document::Meta { name: "twitter:title", content: "{title}" } - document::Meta { name: "twitter:description", content: "{description}" } - document::Meta { name: "twitter:image", content: "{og_image_url}" } - document::Meta { name: "twitter:creator", content: "@{author_handle}" } + EntryOgMeta { + title: title.to_string(), + description: description, + image_url: og_image_url, + canonical_url: canonical_url, + author_handle: author_handle, + book_title: Some(book_title().to_string()), + } document::Link { rel: "stylesheet", href: ENTRY_CSS } div { class: "entry-page-layout", @@ -446,7 +466,7 @@ pub fn EntryCard( /// Metadata header showing title, authors, date, tags #[component] -fn EntryMetadata( +pub fn EntryMetadata( entry_view: EntryView<'static>, created_at: Datetime, entry_uri: AtUri<'static>, @@ -584,7 +604,7 @@ fn EntryMetadata( /// Navigation button for prev/next entries #[component] -fn NavButton( +pub fn NavButton( direction: &'static str, entry: EntryView<'static>, ident: AtIdentifier<'static>, diff --git a/crates/weaver-app/src/components/identity.rs b/crates/weaver-app/src/components/identity.rs index 59fa54b..7dda4f4 100644 --- a/crates/weaver-app/src/components/identity.rs +++ b/crates/weaver-app/src/components/identity.rs @@ -6,6 +6,42 @@ use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier}; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::notebook::NotebookView; +/// OpenGraph and Twitter Card meta tags for profile/repository pages +#[component] +pub fn ProfileOgMeta( + display_name: String, + handle: String, + bio: String, + image_url: String, + canonical_url: String, + notebook_count: usize, +) -> Element { + let page_title = format!("{} (@{}) | Weaver", display_name, handle); + let full_description = if notebook_count > 0 { + format!("{} notebooks · {}", notebook_count, bio) + } else if bio.is_empty() { + format!("@{} on Weaver", handle) + } else { + bio.clone() + }; + + rsx! { + document::Title { "{page_title}" } + document::Meta { property: "og:title", content: "{display_name}" } + document::Meta { property: "og:description", content: "{full_description}" } + document::Meta { property: "og:image", content: "{image_url}" } + document::Meta { property: "og:type", content: "profile" } + document::Meta { property: "og:url", content: "{canonical_url}" } + document::Meta { property: "og:site_name", content: "Weaver" } + document::Meta { property: "profile:username", content: "{handle}" } + document::Meta { name: "twitter:card", content: "summary_large_image" } + document::Meta { name: "twitter:title", content: "{display_name}" } + document::Meta { name: "twitter:description", content: "{full_description}" } + document::Meta { name: "twitter:image", content: "{image_url}" } + document::Meta { name: "twitter:creator", content: "@{handle}" } + } +} + const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); #[component] @@ -38,8 +74,57 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { #[cfg(feature = "fullstack-server")] profile_result?; + // Build OG metadata when profile is available + let og_meta = match &*profile.read() { + Some(profile_view) => { + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + let (display_name, handle, bio) = match &profile_view.inner { + ProfileDataViewInner::ProfileView(p) => ( + p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), + p.handle.as_ref().to_string(), + p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), + ), + ProfileDataViewInner::ProfileViewDetailed(p) => ( + p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), + p.handle.as_ref().to_string(), + p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), + ), + ProfileDataViewInner::TangledProfileView(p) => ( + String::new(), + p.handle.as_ref().to_string(), + String::new(), + ), + _ => (String::new(), "unknown".to_string(), String::new()), + }; + + let notebook_count = notebooks.read().as_ref().map(|n| n.len()).unwrap_or(0); + + let base = if crate::env::WEAVER_APP_ENV == "dev" { + format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT) + } else { + crate::env::WEAVER_APP_HOST.to_string() + }; + let og_image_url = format!("{}/og/profile/{}.png", base, ident()); + let canonical_url = format!("{}/{}", base, ident()); + + Some(rsx! { + ProfileOgMeta { + display_name: if display_name.is_empty() { handle.clone() } else { display_name }, + handle, + bio, + image_url: og_image_url, + canonical_url, + notebook_count, + } + }) + } + None => None, + }; + rsx! { document::Stylesheet { href: NOTEBOOK_CARD_CSS } + {og_meta} div { class: "repository-layout", // Profile sidebar (desktop) / header (mobile) diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index f812cd1..557df5a 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -7,7 +7,10 @@ pub use css::NotebookCss; mod entry; #[allow(unused_imports)] -pub use entry::{EntryCard, EntryMarkdown, EntryPage}; +pub use entry::{ + ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, NavButton, + extract_preview, +}; pub mod identity; #[allow(unused_imports)] diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs index 4bd0096..ddffd75 100644 --- a/crates/weaver-app/src/data.rs +++ b/crates/weaver-app/src/data.rs @@ -849,6 +849,161 @@ pub fn use_is_owner_async( }) } +// ============================================================================ +// Standalone Entry by Rkey Hooks +// ============================================================================ + +/// Fetches standalone entry data by rkey with SSR support. +/// Returns entry + optional notebook context if entry is in exactly one notebook. +#[cfg(feature = "fullstack-server")] +pub fn use_standalone_entry_data( + ident: ReadSignal>, + rkey: ReadSignal, +) -> ( + Result)>>, RenderError>, + Memo>, +) { + let fetcher = use_context::(); + let res = use_server_future(use_reactive!(|(ident, rkey)| { + let fetcher = fetcher.clone(); + async move { + match fetcher.get_entry_by_rkey(ident(), rkey()).await { + Ok(Some(data)) => { + let entry_json = serde_json::to_value(&data.entry).ok()?; + let entry_view_json = serde_json::to_value(&data.entry_view).ok()?; + let notebook_ctx_json = data.notebook_context.as_ref().map(|ctx| { + let notebook_json = serde_json::to_value(&ctx.notebook).ok()?; + let book_entry_json = serde_json::to_value(&ctx.book_entry_view).ok()?; + Some((notebook_json, book_entry_json)) + }).flatten(); + Some((entry_json, entry_view_json, notebook_ctx_json)) + } + Ok(None) => None, + Err(e) => { + tracing::error!("[use_standalone_entry_data] fetch error: {:?}", e); + None + } + } + } + })); + + let memo = use_memo(use_reactive!(|res| { + use crate::fetch::{StandaloneEntryData, NotebookContext}; + use weaver_api::sh_weaver::notebook::{EntryView, entry::Entry, BookEntryView, NotebookView}; + + let res = res.as_ref().ok()?; + let Some(Some((entry_json, entry_view_json, notebook_ctx_json))) = res.read().clone() else { + return None; + }; + + let entry: Entry<'static> = jacquard::from_json_value::(entry_json).ok()?; + let entry_view: EntryView<'static> = jacquard::from_json_value::(entry_view_json).ok()?; + let notebook_context = notebook_ctx_json.map(|(notebook_json, book_entry_json)| { + let notebook: NotebookView<'static> = jacquard::from_json_value::(notebook_json).ok()?; + let book_entry_view: BookEntryView<'static> = jacquard::from_json_value::(book_entry_json).ok()?; + Some(NotebookContext { notebook, book_entry_view }) + }).flatten(); + + Some(StandaloneEntryData { entry, entry_view, notebook_context }) + })); + + (res, memo) +} + +/// Fetches standalone entry data client-side only (no SSR) +#[cfg(not(feature = "fullstack-server"))] +pub fn use_standalone_entry_data( + ident: ReadSignal>, + rkey: ReadSignal, +) -> ( + Resource>, + Memo>, +) { + let fetcher = use_context::(); + let res = use_resource(move || { + let fetcher = fetcher.clone(); + async move { + fetcher + .get_entry_by_rkey(ident(), rkey()) + .await + .ok() + .flatten() + .map(|arc| (*arc).clone()) + } + }); + let memo = use_memo(move || res.read().clone().flatten()); + (res, memo) +} + +/// Fetches notebook entry by rkey with SSR support. +#[cfg(feature = "fullstack-server")] +pub fn use_notebook_entry_by_rkey( + ident: ReadSignal>, + book_title: ReadSignal, + rkey: ReadSignal, +) -> ( + Result>, RenderError>, + Memo, Entry<'static>)>>, +) { + let fetcher = use_context::(); + let res = use_server_future(use_reactive!(|(ident, book_title, rkey)| { + let fetcher = fetcher.clone(); + async move { + match fetcher.get_notebook_entry_by_rkey(ident(), book_title(), rkey()).await { + Ok(Some(data)) => { + let book_entry_json = serde_json::to_value(&data.0).ok()?; + let entry_json = serde_json::to_value(&data.1).ok()?; + Some((book_entry_json, entry_json)) + } + Ok(None) => None, + Err(e) => { + tracing::error!("[use_notebook_entry_by_rkey] fetch error: {:?}", e); + None + } + } + } + })); + + let memo = use_memo(use_reactive!(|res| { + let res = res.as_ref().ok()?; + if let Some(Some((book_entry_json, entry_json))) = &*res.read() { + let book_entry: BookEntryView<'static> = jacquard::from_json_value::(book_entry_json.clone()).ok()?; + let entry: Entry<'static> = jacquard::from_json_value::(entry_json.clone()).ok()?; + Some((book_entry, entry)) + } else { + None + } + })); + + (res, memo) +} + +/// Fetches notebook entry by rkey client-side only (no SSR) +#[cfg(not(feature = "fullstack-server"))] +pub fn use_notebook_entry_by_rkey( + ident: ReadSignal>, + book_title: ReadSignal, + rkey: ReadSignal, +) -> ( + Resource, Entry<'static>)>>, + Memo, Entry<'static>)>>, +) { + let fetcher = use_context::(); + let res = use_resource(move || { + let fetcher = fetcher.clone(); + async move { + fetcher + .get_notebook_entry_by_rkey(ident(), book_title(), rkey()) + .await + .ok() + .flatten() + .map(|arc| (*arc).clone()) + } + }); + let memo = use_memo(move || res.read().clone().flatten()); + (res, memo) +} + #[cfg(feature = "fullstack-server")] #[put("/cache/{ident}/{cid}?name", cache: Extension>)] pub async fn cache_blob(ident: SmolStr, cid: SmolStr, name: Option) -> Result<()> { diff --git a/crates/weaver-app/src/fetch.rs b/crates/weaver-app/src/fetch.rs index 878dd73..4d9e5f5 100644 --- a/crates/weaver-app/src/fetch.rs +++ b/crates/weaver-app/src/fetch.rs @@ -32,6 +32,7 @@ use tokio::sync::RwLock; use weaver_api::app_bsky::actor::get_profile::GetProfile; use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; use weaver_api::sh_weaver::actor::ProfileDataViewInner; +use weaver_api::sh_weaver::notebook::EntryView; use weaver_api::{ com_atproto::repo::strong_ref::StrongRef, sh_weaver::{ @@ -51,6 +52,23 @@ struct UfosRecord { time_us: u64, } +/// Data for a standalone entry (may or may not have notebook context) +#[derive(Clone, PartialEq)] +pub struct StandaloneEntryData { + pub entry: Entry<'static>, + pub entry_view: EntryView<'static>, + /// Present if entry is in exactly one notebook + pub notebook_context: Option, +} + +/// Notebook context for an entry +#[derive(Clone, PartialEq)] +pub struct NotebookContext { + pub notebook: NotebookView<'static>, + /// BookEntryView with prev/next navigation + pub book_entry_view: BookEntryView<'static>, +} + pub struct Client { pub oauth_client: Arc>, pub session: RwLock>>>>, @@ -566,6 +584,145 @@ impl Fetcher { Ok(Arc::new(profile_view)) } + + /// Fetch an entry by rkey with optional notebook context lookup. + pub async fn get_entry_by_rkey( + &self, + ident: AtIdentifier<'static>, + rkey: SmolStr, + ) -> Result>> { + use jacquard::types::aturi::AtUri; + + let client = self.get_client(); + + // Fetch entry directly by rkey + let (entry_view, entry) = client + .fetch_entry_by_rkey(&ident, &rkey) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + // Try to find notebook context via constellation + let entry_uri = entry_view.uri.clone(); + let at_uri = AtUri::new(entry_uri.as_ref()).map_err(|e| { + dioxus::CapturedError::from_display(format!("Invalid entry URI: {}", e)) + })?; + + let (total, first_notebook) = client + .find_notebooks_for_entry(&at_uri) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + // Only provide notebook context if entry is in exactly one notebook + let notebook_context = if total == 1 { + if let Some(notebook_id) = first_notebook { + // Construct notebook URI from RecordId + let notebook_uri_str = format!( + "at://{}/{}/{}", + notebook_id.did.as_str(), + notebook_id.collection.as_str(), + notebook_id.rkey.0.as_str() + ); + let notebook_uri = AtUri::new_owned(notebook_uri_str).map_err(|e| { + dioxus::CapturedError::from_display(format!("Invalid notebook URI: {}", e)) + })?; + + // Fetch notebook and find entry position + if let Ok((notebook, entries)) = client.view_notebook(¬ebook_uri).await { + if let Ok(Some(book_entry_view)) = client + .entry_in_notebook_by_rkey(¬ebook, &entries, &rkey) + .await + { + Some(NotebookContext { + notebook: notebook.into_static(), + book_entry_view: book_entry_view.into_static(), + }) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + Ok(Some(Arc::new(StandaloneEntryData { + entry, + entry_view, + notebook_context, + }))) + } + + /// Fetch an entry by rkey within a specific notebook context. + /// + /// The book_title parameter provides the notebook context. + /// Returns BookEntryView without prev/next if entry is in multiple notebooks. + pub async fn get_notebook_entry_by_rkey( + &self, + ident: AtIdentifier<'static>, + book_title: SmolStr, + rkey: SmolStr, + ) -> Result, Entry<'static>)>>> { + use jacquard::types::aturi::AtUri; + + let client = self.get_client(); + + // Fetch entry directly by rkey + let (entry_view, entry) = client + .fetch_entry_by_rkey(&ident, &rkey) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + // Fetch notebook by title + let notebook_result = client + .notebook_by_title(&ident, &book_title) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + let (notebook, entries) = match notebook_result { + Some((n, e)) => (n, e), + None => return Err(dioxus::CapturedError::from_display("Notebook not found")), + }; + + // Find entry position in notebook + let book_entry_view = client + .entry_in_notebook_by_rkey(¬ebook, &entries, &rkey) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + let mut book_entry_view = match book_entry_view { + Some(bev) => bev, + None => { + // Entry not in this notebook's entry list - return basic view without nav + use weaver_api::sh_weaver::notebook::BookEntryView; + BookEntryView::new().entry(entry_view).index(0).build() + } + }; + + // Check if entry is in multiple notebooks - if so, clear prev/next + let entry_uri = book_entry_view.entry.uri.clone(); + let at_uri = AtUri::new(entry_uri.as_ref()).map_err(|e| { + dioxus::CapturedError::from_display(format!("Invalid entry URI: {}", e)) + })?; + + let (total, _) = client + .find_notebooks_for_entry(&at_uri) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + if total >= 2 { + // Entry is in multiple notebooks - clear prev/next to avoid ambiguity + book_entry_view = BookEntryView::new() + .entry(book_entry_view.entry) + .index(book_entry_view.index) + .build(); + } + + Ok(Some(Arc::new((book_entry_view.into_static(), entry)))) + } } // #[cfg(feature = "server")] diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index ca889ed..57b9c48 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -587,6 +587,307 @@ pub async fn og_image( ).into_response()) } +// Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/og/notebook/{ident}/{book_title}", fetcher: Extension>)] +pub async fn og_notebook_image( + ident: SmolStr, + book_title: SmolStr, +) -> Result { + use axum::{ + http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, + response::IntoResponse, + }; + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + // Strip .png extension if present + let book_title = book_title.strip_suffix(".png").unwrap_or(&book_title); + + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { + return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); + }; + + // Fetch notebook data + let notebook_result = fetcher.get_notebook(at_ident.clone(), book_title.into()).await; + + let arc_data = match notebook_result { + Ok(Some(data)) => data, + Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()), + Err(e) => { + tracing::error!("Failed to fetch notebook for OG image: {:?}", e); + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch notebook").into_response()); + } + }; + let (notebook_view, _entries) = arc_data.as_ref(); + + // Build cache key using notebook CID + let notebook_cid = notebook_view.cid.as_ref(); + let cache_key = og::notebook_cache_key(&ident, book_title, notebook_cid); + + // Check cache first + if let Some(cached) = og::get_cached(&cache_key) { + return Ok(( + [ + (CONTENT_TYPE, "image/png"), + (CACHE_CONTROL, "public, max-age=3600"), + ], + cached, + ).into_response()); + } + + // Extract metadata + let title = notebook_view.title + .as_ref() + .map(|t| t.as_ref()) + .unwrap_or("Untitled Notebook"); + + let author_handle = notebook_view.authors.first() + .map(|a| match &a.record.inner { + ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), + _ => "unknown".to_string(), + }) + .unwrap_or_else(|| "unknown".to_string()); + + // Fetch entries to get entry titles and count + let entries_result = fetcher.list_notebook_entries(at_ident.clone(), book_title.into()).await; + let (entry_count, entry_titles) = match entries_result { + Ok(Some(entries)) => { + let count = entries.len(); + let titles: Vec = entries + .iter() + .take(4) + .map(|e| { + e.entry.title + .as_ref() + .map(|t| t.as_ref().to_string()) + .unwrap_or_else(|| "Untitled".to_string()) + }) + .collect(); + (count, titles) + } + _ => (0, vec![]), + }; + + // Generate image + let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles) { + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("Failed to generate notebook OG image: {:?}", e); + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); + } + }; + + // Cache the generated image + og::cache_image(cache_key, png_bytes.clone()); + + Ok(( + [ + (CONTENT_TYPE, "image/png"), + (CACHE_CONTROL, "public, max-age=3600"), + ], + png_bytes, + ).into_response()) +} + +// Route: /og/profile/{ident}.png - OpenGraph image for profile/repository +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/og/profile/{ident}", fetcher: Extension>)] +pub async fn og_profile_image( + ident: SmolStr, +) -> Result { + use axum::{ + http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, + response::IntoResponse, + }; + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + // Strip .png extension if present + let ident = ident.strip_suffix(".png").unwrap_or(&ident); + + let Ok(at_ident) = AtIdentifier::new_owned(ident.to_string()) else { + return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); + }; + + // Fetch profile data + let profile_result = fetcher.fetch_profile(&at_ident).await; + + let profile_view = match profile_result { + Ok(data) => data, + Err(e) => { + tracing::error!("Failed to fetch profile for OG image: {:?}", e); + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response()); + } + }; + + // Extract profile fields based on type + // Use DID as cache key since profiles don't have a CID field + let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner { + ProfileDataViewInner::ProfileView(p) => ( + p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), + p.handle.as_ref().to_string(), + p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), + p.avatar.as_ref().map(|u| u.as_ref().to_string()), + None::, + p.did.as_ref().to_string(), + ), + ProfileDataViewInner::ProfileViewDetailed(p) => ( + p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), + p.handle.as_ref().to_string(), + p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), + p.avatar.as_ref().map(|u| u.as_ref().to_string()), + p.banner.as_ref().map(|u| u.as_ref().to_string()), + p.did.as_ref().to_string(), + ), + ProfileDataViewInner::TangledProfileView(p) => ( + String::new(), + p.handle.as_ref().to_string(), + String::new(), + None, + None, + p.did.as_ref().to_string(), + ), + _ => return Ok((StatusCode::NOT_FOUND, "Profile type not supported").into_response()), + }; + + // Build cache key + let cache_key = og::profile_cache_key(ident, &cache_id); + + // Check cache first + if let Some(cached) = og::get_cached(&cache_key) { + return Ok(( + [ + (CONTENT_TYPE, "image/png"), + (CACHE_CONTROL, "public, max-age=3600"), + ], + cached, + ).into_response()); + } + + // Fetch notebook count + let notebooks_result = fetcher.fetch_notebooks_for_did(&at_ident).await; + let notebook_count = notebooks_result.map(|n| n.len()).unwrap_or(0); + + // Fetch avatar as base64 if available + let avatar_data = if let Some(ref url) = avatar_url { + match reqwest::get(url).await { + Ok(response) if response.status().is_success() => { + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + match response.bytes().await { + Ok(bytes) => { + use base64::Engine; + let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); + Some(format!("data:{};base64,{}", content_type, base64_str)) + } + Err(_) => None, + } + } + _ => None, + } + } else { + None + }; + + // Check for banner and generate appropriate template + let png_bytes = if let Some(ref banner_url) = banner_url { + // Fetch banner image + let banner_data = match reqwest::get(banner_url).await { + Ok(response) if response.status().is_success() => { + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + match response.bytes().await { + Ok(bytes) => { + use base64::Engine; + let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); + Some(format!("data:{};base64,{}", content_type, base64_str)) + } + Err(_) => None, + } + } + _ => None, + }; + + if let Some(banner_data) = banner_data { + match og::generate_profile_banner_og( + &display_name, + &handle, + &bio, + banner_data, + avatar_data.clone(), + notebook_count, + ) { + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("Failed to generate profile banner OG image: {:?}, falling back", e); + og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) + .unwrap_or_default() + } + } + } else { + og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) + .unwrap_or_default() + } + } else { + match og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) { + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("Failed to generate profile OG image: {:?}", e); + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); + } + } + }; + + // Cache the generated image + og::cache_image(cache_key, png_bytes.clone()); + + Ok(( + [ + (CONTENT_TYPE, "image/png"), + (CACHE_CONTROL, "public, max-age=3600"), + ], + png_bytes, + ).into_response()) +} + +// Route: /og/site.png - OpenGraph image for homepage +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/og/site.png")] +pub async fn og_site_image() -> Result { + use axum::{ + http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, + response::IntoResponse, + }; + + // Site OG is static, cache aggressively + static SITE_OG_CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + + let png_bytes = SITE_OG_CACHE.get_or_init(|| { + og::generate_site_og().unwrap_or_default() + }); + + if png_bytes.is_empty() { + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); + } + + Ok(( + [ + (CONTENT_TYPE, "image/png"), + (CACHE_CONTROL, "public, max-age=86400"), + ], + png_bytes.clone(), + ).into_response()) +} + // #[server(endpoint = "static_routes", output = server_fn::codec::Json)] // async fn static_routes() -> Result, ServerFnError> { // // The `Routable` trait has a `static_routes` method that returns all static routes in the enum diff --git a/crates/weaver-app/src/og/mod.rs b/crates/weaver-app/src/og/mod.rs index dfbfaf3..6087bc1 100644 --- a/crates/weaver-app/src/og/mod.rs +++ b/crates/weaver-app/src/og/mod.rs @@ -89,6 +89,44 @@ pub struct HeroImageTemplate { pub author_handle: String, } +/// Notebook index template +#[derive(Template)] +#[template(path = "og_notebook.svg", escape = "none")] +pub struct NotebookTemplate { + pub title_lines: Vec, + pub author_handle: String, + pub entry_count: usize, + pub entry_titles: Vec, +} + +/// Profile template (text-only, no banner) +#[derive(Template)] +#[template(path = "og_profile.svg", escape = "none")] +pub struct ProfileTemplate { + pub avatar_data: Option, + pub display_name_lines: Vec, + pub handle: String, + pub bio_lines: Vec, + pub notebook_count: usize, +} + +/// Profile template with banner image +#[derive(Template)] +#[template(path = "og_profile_banner.svg", escape = "none")] +pub struct ProfileBannerTemplate { + pub banner_image_data: String, + pub avatar_data: Option, + pub display_name_lines: Vec, + pub handle: String, + pub bio_lines: Vec, + pub notebook_count: usize, +} + +/// Site homepage template +#[derive(Template)] +#[template(path = "og_site.svg", escape = "none")] +pub struct SiteTemplate {} + /// Global font database, initialized once static FONTDB: OnceLock = OnceLock::new(); @@ -98,8 +136,11 @@ fn get_fontdb() -> &'static fontdb::Database { // Load IBM Plex Sans from embedded bytes let font_data = include_bytes!("../../assets/fonts/IBMPlexSans-VariableFont_wdth,wght.ttf"); db.load_font_data(font_data.to_vec()); + // Load IBM Plex Sans Bold (static weight for proper bold rendering) + let font_data = include_bytes!("../../assets/fonts/IBMPlexSans-Bold.ttf"); + db.load_font_data(font_data.to_vec()); let font_data = - include_bytes!("../../assets/fonts/ioskeley-mono/IoskeleyMono-Regular.woff2"); + include_bytes!("../../assets/fonts/ioskeley-mono/IoskeleyMono-Regular.ttf"); db.load_font_data(font_data.to_vec()); db }) @@ -183,6 +224,116 @@ pub fn generate_hero_image( render_svg_to_png(&svg) } +/// Generate cache key for notebook OG images +pub fn notebook_cache_key(ident: &str, book: &str, cid: &str) -> String { + format!("notebook/{}/{}/{}", ident, book, cid) +} + +/// Generate cache key for profile OG images +pub fn profile_cache_key(ident: &str, cid: &str) -> String { + format!("profile/{}/{}", ident, cid) +} + +/// Generate a notebook index OG image +pub fn generate_notebook_og( + title: &str, + author_handle: &str, + entry_count: usize, + entry_titles: Vec, +) -> Result, OgError> { + let title_lines = wrap_title(title, 40, 2); + // Limit to first 4 entries, truncate long titles + let entry_titles: Vec = entry_titles + .into_iter() + .take(4) + .map(|t| { + if t.len() > 60 { + format!("{}...", &t[..57]) + } else { + t + } + }) + .collect(); + + let template = NotebookTemplate { + title_lines, + author_handle: author_handle.to_string(), + entry_count, + entry_titles, + }; + + let svg = template + .render() + .map_err(|e| OgError::TemplateError(e.to_string()))?; + + render_svg_to_png(&svg) +} + +/// Generate a profile OG image (text-only version) +pub fn generate_profile_og( + display_name: &str, + handle: &str, + bio: &str, + avatar_data: Option, + notebook_count: usize, +) -> Result, OgError> { + let display_name_lines = wrap_title(display_name, 30, 2); + let bio_lines = wrap_title(bio, 60, 4); + + let template = ProfileTemplate { + avatar_data, + display_name_lines, + handle: handle.to_string(), + bio_lines, + notebook_count, + }; + + let svg = template + .render() + .map_err(|e| OgError::TemplateError(e.to_string()))?; + + render_svg_to_png(&svg) +} + +/// Generate a profile OG image with banner +pub fn generate_profile_banner_og( + display_name: &str, + handle: &str, + bio: &str, + banner_image_data: String, + avatar_data: Option, + notebook_count: usize, +) -> Result, OgError> { + let display_name_lines = wrap_title(display_name, 25, 1); + let bio_lines = wrap_title(bio, 70, 1); + + let template = ProfileBannerTemplate { + banner_image_data, + avatar_data, + display_name_lines, + handle: handle.to_string(), + bio_lines, + notebook_count, + }; + + let svg = template + .render() + .map_err(|e| OgError::TemplateError(e.to_string()))?; + + render_svg_to_png(&svg) +} + +/// Generate site homepage OG image +pub fn generate_site_og() -> Result, OgError> { + let template = SiteTemplate {}; + + let svg = template + .render() + .map_err(|e| OgError::TemplateError(e.to_string()))?; + + render_svg_to_png(&svg) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/weaver-app/src/views/drafts.rs b/crates/weaver-app/src/views/drafts.rs index 04b992b..6eea6b0 100644 --- a/crates/weaver-app/src/views/drafts.rs +++ b/crates/weaver-app/src/views/drafts.rs @@ -144,10 +144,7 @@ pub fn DraftsList(ident: ReadSignal>) -> Element { /// Edit an existing draft by TID. #[component] -pub fn DraftEdit( - ident: ReadSignal>, - tid: ReadSignal, -) -> Element { +pub fn DraftEdit(ident: ReadSignal>, tid: ReadSignal) -> Element { use crate::components::editor::MarkdownEditor; use crate::views::editor::EditorCss; @@ -182,25 +179,6 @@ pub fn NewDraft( } } -/// View a standalone entry by rkey. -#[component] -pub fn StandaloneEntry( - ident: ReadSignal>, - rkey: ReadSignal, -) -> Element { - // Construct AT-URI for the entry - let entry_uri = use_memo(move || { - format!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey()) - }); - - rsx! { - div { class: "standalone-entry-page", - p { "Standalone entry view - implementation pending" } - p { "URI: {entry_uri}" } - } - } -} - /// Edit a standalone entry. #[component] pub fn StandaloneEntryEdit( @@ -211,9 +189,8 @@ pub fn StandaloneEntryEdit( use crate::views::editor::EditorCss; // Construct AT-URI for the entry - let entry_uri = use_memo(move || { - format!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey()) - }); + let entry_uri = + use_memo(move || format!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey())); rsx! { EditorCss {} @@ -223,22 +200,6 @@ pub fn StandaloneEntryEdit( } } -/// View a notebook entry by rkey. -#[component] -pub fn NotebookEntryByRkey( - ident: ReadSignal>, - book_title: ReadSignal, - rkey: ReadSignal, -) -> Element { - rsx! { - div { class: "notebook-entry-rkey-page", - p { "Notebook entry by rkey - implementation pending" } - p { "Notebook: {book_title}" } - p { "Entry rkey: {rkey}" } - } - } -} - /// Edit a notebook entry by rkey. #[component] pub fn NotebookEntryEdit( @@ -250,9 +211,8 @@ pub fn NotebookEntryEdit( use crate::views::editor::EditorCss; // Construct AT-URI for the entry - let entry_uri = use_memo(move || { - format!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey()) - }); + let entry_uri = + use_memo(move || format!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey())); rsx! { EditorCss {} diff --git a/crates/weaver-app/src/views/entry.rs b/crates/weaver-app/src/views/entry.rs new file mode 100644 index 0000000..2c80268 --- /dev/null +++ b/crates/weaver-app/src/views/entry.rs @@ -0,0 +1,258 @@ +#![allow(non_snake_case)] + +use dioxus::prelude::*; +use jacquard::smol_str::{SmolStr, ToSmolStr}; +use jacquard::types::string::AtIdentifier; + +use crate::components::NotebookCss; +use crate::components::css::DefaultNotebookCss; + +/// View a standalone entry by rkey (not in notebook context). +#[component] +pub fn StandaloneEntry( + ident: ReadSignal>, + rkey: ReadSignal, +) -> Element { + use crate::components::{ + ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, extract_preview, + }; + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + let (entry_res, entry_data) = crate::data::use_standalone_entry_data(ident, rkey); + + #[cfg(feature = "fullstack-server")] + let _entry_res = entry_res?; + + match &*entry_data.read() { + Some(data) => { + let entry_view = &data.entry_view; + let entry_record = &data.entry; + + let title = entry_view + .title + .as_ref() + .map(|t| t.as_ref()) + .unwrap_or("Untitled"); + + let author_handle = entry_view + .authors + .first() + .map(|a| match &a.record.inner { + ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), + _ => "unknown".to_string(), + }) + .unwrap_or_else(|| "unknown".to_string()); + + let base = if crate::env::WEAVER_APP_ENV == "dev" { + format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT) + } else { + crate::env::WEAVER_APP_HOST.to_string() + }; + let canonical_url = format!("{}/{}/e/{}", base, ident(), rkey()); + let description = extract_preview(&entry_record.content, 160); + + let entry_signal = use_signal(|| data.entry.clone()); + + if let Some(ref ctx) = data.notebook_context { + let book_entry_view = &ctx.book_entry_view; + let notebook = &ctx.notebook; + let book_title: SmolStr = notebook + .title + .as_ref() + .map(|t| t.as_ref().into()) + .unwrap_or_else(|| "Untitled".into()); + + rsx! { + EntryOgMeta { + title: title.to_string(), + description: description.clone(), + image_url: String::new(), + canonical_url: canonical_url.clone(), + author_handle: author_handle.clone(), + book_title: Some(book_title.to_string()), + } + document::Link { rel: "stylesheet", href: ENTRY_CSS } + NotebookCss { ident: ident().to_smolstr(), notebook: book_title.clone() } + + div { class: "entry-page-layout", + if let Some(ref prev) = book_entry_view.prev { + div { class: "nav-gutter nav-prev", + NavButton { + direction: "prev", + entry: prev.entry.clone(), + ident: ident(), + book_title: book_title.clone() + } + } + } + + div { class: "entry-content-main notebook-content", + EntryMetadata { + entry_view: entry_view.clone(), + created_at: entry_record.created_at.clone(), + entry_uri: entry_view.uri.clone(), + book_title: Some(book_title.clone()), + ident: ident() + } + EntryMarkdown { content: entry_signal, ident } + } + + if let Some(ref next) = book_entry_view.next { + div { class: "nav-gutter nav-next", + NavButton { + direction: "next", + entry: next.entry.clone(), + ident: ident(), + book_title: book_title.clone() + } + } + } + } + } + } else { + // Standalone view without notebook navigation + rsx! { + EntryOgMeta { + title: title.to_string(), + description: description.clone(), + image_url: String::new(), + canonical_url: canonical_url.clone(), + author_handle: author_handle.clone(), + } + document::Link { rel: "stylesheet", href: ENTRY_CSS } + DefaultNotebookCss {} + + + div { class: "entry-page-layout", + div { class: "entry-content-main notebook-content", + EntryMetadata { + entry_view: entry_view.clone(), + created_at: entry_record.created_at.clone(), + entry_uri: entry_view.uri.clone(), + book_title: None, + ident: ident() + } + EntryMarkdown { content: entry_signal, ident } + } + } + } + } + } + None => rsx! { p { "Loading..." } }, + } +} + +/// View a notebook entry by rkey. +#[component] +pub fn NotebookEntryByRkey( + ident: ReadSignal>, + book_title: ReadSignal, + rkey: ReadSignal, +) -> Element { + use crate::components::{ + ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, extract_preview, + }; + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + let (entry_res, entry_data) = crate::data::use_notebook_entry_by_rkey(ident, book_title, rkey); + + #[cfg(feature = "fullstack-server")] + let _entry_res = entry_res?; + + match &*entry_data.read() { + Some((book_entry_view, entry_record)) => { + let entry_view = &book_entry_view.entry; + + let title = entry_view + .title + .as_ref() + .map(|t| t.as_ref()) + .unwrap_or("Untitled"); + + let entry_path = entry_view + .path + .as_ref() + .map(|p| p.as_ref().to_string()) + .unwrap_or_else(|| title.to_string()); + + let author_handle = entry_view + .authors + .first() + .map(|a| match &a.record.inner { + ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), + _ => "unknown".to_string(), + }) + .unwrap_or_else(|| "unknown".to_string()); + + let base = if crate::env::WEAVER_APP_ENV == "dev" { + format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT) + } else { + crate::env::WEAVER_APP_HOST.to_string() + }; + let canonical_url = format!("{}/{}/{}/e/{}", base, ident(), book_title(), rkey()); + let og_image_url = format!( + "{}/og/{}/{}/{}.png", + base, + ident(), + book_title(), + entry_path + ); + + let description = extract_preview(&entry_record.content, 160); + let entry_signal = use_signal(|| entry_record.clone()); + + rsx! { + EntryOgMeta { + title: title.to_string(), + description: description, + image_url: og_image_url, + canonical_url: canonical_url, + author_handle: author_handle, + book_title: Some(book_title().to_string()), + } + document::Link { rel: "stylesheet", href: ENTRY_CSS } + NotebookCss { ident: ident().to_smolstr(), notebook: book_title() } + + div { class: "entry-page-layout", + if let Some(ref prev) = book_entry_view.prev { + div { class: "nav-gutter nav-prev", + NavButton { + direction: "prev", + entry: prev.entry.clone(), + ident: ident(), + book_title: book_title() + } + } + } + + div { class: "entry-content-main notebook-content", + EntryMetadata { + entry_view: entry_view.clone(), + created_at: entry_record.created_at.clone(), + entry_uri: entry_view.uri.clone(), + book_title: Some(book_title()), + ident: ident() + } + EntryMarkdown { content: entry_signal, ident } + } + + if let Some(ref next) = book_entry_view.next { + div { class: "nav-gutter nav-next", + NavButton { + direction: "next", + entry: next.entry.clone(), + ident: ident(), + book_title: book_title() + } + } + } + } + } + } + None => rsx! { p { "Loading..." } }, + } +} diff --git a/crates/weaver-app/src/views/home.rs b/crates/weaver-app/src/views/home.rs index 4f0cb23..4a98f49 100644 --- a/crates/weaver-app/src/views/home.rs +++ b/crates/weaver-app/src/views/home.rs @@ -2,6 +2,35 @@ use crate::{Route, components::identity::NotebookCard, data}; use dioxus::prelude::*; use jacquard::types::aturi::AtUri; +/// OpenGraph and Twitter Card meta tags for the homepage +#[component] +pub fn SiteOgMeta() -> Element { + let base = if crate::env::WEAVER_APP_ENV == "dev" { + format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT) + } else { + crate::env::WEAVER_APP_HOST.to_string() + }; + + let title = "Weaver"; + let description = "Share your words, your way."; + let image_url = format!("{}/og/site.png", base); + let canonical_url = base; + + rsx! { + document::Title { "{title}" } + document::Meta { property: "og:title", content: "{title}" } + document::Meta { property: "og:description", content: "{description}" } + document::Meta { property: "og:image", content: "{image_url}" } + document::Meta { property: "og:type", content: "website" } + document::Meta { property: "og:url", content: "{canonical_url}" } + document::Meta { property: "og:site_name", content: "Weaver" } + document::Meta { name: "twitter:card", content: "summary_large_image" } + document::Meta { name: "twitter:title", content: "{title}" } + document::Meta { name: "twitter:description", content: "{description}" } + document::Meta { name: "twitter:image", content: "{image_url}" } + } +} + const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); /// The Home page component that will be rendered when the current route is `[Route::Home]` @@ -18,6 +47,7 @@ pub fn Home() -> Element { .transpose()?; rsx! { + SiteOgMeta {} document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } div { class: "record-view-container", diff --git a/crates/weaver-app/src/views/mod.rs b/crates/weaver-app/src/views/mod.rs index f02822a..aa53f06 100644 --- a/crates/weaver-app/src/views/mod.rs +++ b/crates/weaver-app/src/views/mod.rs @@ -30,7 +30,7 @@ mod editor; pub use editor::Editor; mod drafts; -pub use drafts::{ - DraftEdit, DraftsList, NewDraft, NotebookEntryByRkey, NotebookEntryEdit, StandaloneEntry, - StandaloneEntryEdit, -}; +pub use drafts::{DraftEdit, DraftsList, NewDraft, NotebookEntryEdit, StandaloneEntryEdit}; + +mod entry; +pub use entry::{NotebookEntryByRkey, StandaloneEntry}; diff --git a/crates/weaver-app/src/views/navbar.rs b/crates/weaver-app/src/views/navbar.rs index c61383d..445da4a 100644 --- a/crates/weaver-app/src/views/navbar.rs +++ b/crates/weaver-app/src/views/navbar.rs @@ -5,7 +5,7 @@ use crate::components::login::LoginModal; use crate::data::{use_get_handle, use_load_handle}; use crate::fetch::Fetcher; use dioxus::prelude::*; -use dioxus_primitives::toast::{use_toast, ToastOptions}; +use dioxus_primitives::toast::{ToastOptions, use_toast}; use jacquard::types::string::Did; const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); @@ -120,15 +120,23 @@ pub fn Navbar() -> Element { let handle = route_handle.unwrap_or(ident.clone()); rsx! { span { class:"breadcrumb-separator"," > "} - span { class:"breadcrumb breadcrumb-current","@{handle}"} + Link { + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" + } } }, - Route::DraftEdit { ident, tid } => { + Route::DraftEdit { ident, .. } => { let route_handle = route_handle.read().clone(); let handle = route_handle.unwrap_or(ident.clone()); rsx! { span { class:"breadcrumb-separator"," > "} - span { class:"breadcrumb breadcrumb-current","@{handle}"} + Link { + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" + } } }, Route::NewDraft { ident, notebook } => { @@ -165,7 +173,11 @@ pub fn Navbar() -> Element { let handle = route_handle.unwrap_or(ident.clone()); rsx! { span { class:"breadcrumb-separator"," > "} - span { class:"breadcrumb breadcrumb-current","@{handle}"} + Link { + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" + } } }, Route::StandaloneEntryEdit { ident, .. } => { @@ -173,7 +185,11 @@ pub fn Navbar() -> Element { let handle = route_handle.unwrap_or(ident.clone()); rsx! { span { class:"breadcrumb-separator"," > "} - span { class:"breadcrumb breadcrumb-current","@{handle}"} + Link { + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" + } } }, Route::NotebookEntryByRkey { ident, book_title, .. } => { diff --git a/crates/weaver-app/src/views/notebook.rs b/crates/weaver-app/src/views/notebook.rs index 80d460d..c97db1b 100644 --- a/crates/weaver-app/src/views/notebook.rs +++ b/crates/weaver-app/src/views/notebook.rs @@ -11,6 +11,39 @@ use jacquard::{ types::ident::AtIdentifier, }; +/// OpenGraph and Twitter Card meta tags for notebook index pages +#[component] +pub fn NotebookOgMeta( + title: String, + description: String, + image_url: String, + canonical_url: String, + author_handle: String, + entry_count: usize, +) -> Element { + let page_title = format!("{} | @{} | Weaver", title, author_handle); + let full_description = if entry_count > 0 { + format!("{} entries · {}", entry_count, description) + } else { + description.clone() + }; + + rsx! { + document::Title { "{page_title}" } + document::Meta { property: "og:title", content: "{title}" } + document::Meta { property: "og:description", content: "{full_description}" } + document::Meta { property: "og:image", content: "{image_url}" } + document::Meta { property: "og:type", content: "website" } + document::Meta { property: "og:url", content: "{canonical_url}" } + document::Meta { property: "og:site_name", content: "Weaver" } + document::Meta { name: "twitter:card", content: "summary_large_image" } + document::Meta { name: "twitter:title", content: "{title}" } + document::Meta { name: "twitter:description", content: "{full_description}" } + document::Meta { name: "twitter:image", content: "{image_url}" } + document::Meta { name: "twitter:creator", content: "@{author_handle}" } + } +} + const ENTRY_CARD_CSS: Asset = asset!("/assets/styling/entry-card.css"); /// The Blog page component that will be rendered when the current route is `[Route::Blog]` @@ -70,7 +103,44 @@ pub fn NotebookIndex( let (notebook_view, _) = data; let author_count = notebook_view.authors.len(); + // Build OG metadata + let og_title = notebook_view.title + .as_ref() + .map(|t| t.as_ref().to_string()) + .unwrap_or_else(|| "Untitled Notebook".to_string()); + + let og_author = { + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + notebook_view.authors.first() + .map(|a| match &a.record.inner { + ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), + _ => "unknown".to_string(), + }) + .unwrap_or_else(|| "unknown".to_string()) + }; + + // NotebookView doesn't expose description directly, use empty for now + let og_description = String::new(); + + let base = if crate::env::WEAVER_APP_ENV == "dev" { + format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT) + } else { + crate::env::WEAVER_APP_HOST.to_string() + }; + let og_image_url = format!("{}/og/notebook/{}/{}.png", base, ident(), book_title()); + let canonical_url = format!("{}/{}/{}", base, ident(), book_title()); + rsx! { + NotebookOgMeta { + title: og_title, + description: og_description, + image_url: og_image_url, + canonical_url, + author_handle: og_author, + entry_count: entries.len(), + } div { class: "notebook-layout", aside { class: "notebook-sidebar", NotebookCover { diff --git a/crates/weaver-app/templates/og_hero_image.svg b/crates/weaver-app/templates/og_hero_image.svg index d98c567..939ae1e 100644 --- a/crates/weaver-app/templates/og_hero_image.svg +++ b/crates/weaver-app/templates/og_hero_image.svg @@ -11,8 +11,8 @@ {% endfor %} - {{ notebook_title }} · @{{ author_handle }} + {{ notebook_title }} · @{{ author_handle }} - weaver.sh + weaver.sh diff --git a/crates/weaver-app/templates/og_notebook.svg b/crates/weaver-app/templates/og_notebook.svg new file mode 100644 index 0000000..d50e0a6 --- /dev/null +++ b/crates/weaver-app/templates/og_notebook.svg @@ -0,0 +1,20 @@ + + + + + + {% for line in title_lines %} + {{ line }} + {% endfor %} + + + @{{ author_handle }} · {{ entry_count }} {% if entry_count == 1 %}entry{% else %}entries{% endif %} + + + {% for entry_title in entry_titles %} + {{ entry_title }} + {% endfor %} + + + weaver.sh + diff --git a/crates/weaver-app/templates/og_profile.svg b/crates/weaver-app/templates/og_profile.svg new file mode 100644 index 0000000..02b71b5 --- /dev/null +++ b/crates/weaver-app/templates/og_profile.svg @@ -0,0 +1,33 @@ + + + + + + {% if avatar_data.is_some() %} + + + + + + + {% endif %} + + + {% for line in display_name_lines %} + {{ line }} + {% endfor %} + + + @{{ handle }} + + + {% for line in bio_lines %} + {{ line }} + {% endfor %} + + + {{ notebook_count }} {% if notebook_count == 1 %}notebook{% else %}notebooks{% endif %} + + + weaver.sh + diff --git a/crates/weaver-app/templates/og_profile_banner.svg b/crates/weaver-app/templates/og_profile_banner.svg new file mode 100644 index 0000000..77c66a0 --- /dev/null +++ b/crates/weaver-app/templates/og_profile_banner.svg @@ -0,0 +1,33 @@ + + + + + + + + + {% if avatar_data.is_some() %} + + + + + + + {% endif %} + + + {% for line in display_name_lines %} + {{ line }} + {% endfor %} + + + @{{ handle }} · {{ notebook_count }} {% if notebook_count == 1 %}notebook{% else %}notebooks{% endif %} + + + {% if bio_lines.first().is_some() %} + {{ bio_lines.first().unwrap() }} + {% endif %} + + + weaver.sh + diff --git a/crates/weaver-app/templates/og_site.svg b/crates/weaver-app/templates/og_site.svg new file mode 100644 index 0000000..985fd48 --- /dev/null +++ b/crates/weaver-app/templates/og_site.svg @@ -0,0 +1,13 @@ + + + + + + Weaver + + + Share your words, your way. + + + weaver.sh + diff --git a/crates/weaver-app/templates/og_text_only.svg b/crates/weaver-app/templates/og_text_only.svg index 544bcc9..caf7e85 100644 --- a/crates/weaver-app/templates/og_text_only.svg +++ b/crates/weaver-app/templates/og_text_only.svg @@ -8,7 +8,7 @@ {% endfor %} - {{ notebook_title }} · @{{ author_handle }} + {{ notebook_title }} · @{{ author_handle }} {% for line in content_lines %} @@ -18,5 +18,5 @@ - weaver.sh + weaver.sh diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs index b614b13..d54a61a 100644 --- a/crates/weaver-common/src/agent.rs +++ b/crates/weaver-common/src/agent.rs @@ -968,6 +968,24 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { &self, entry_uri: &AtUri<'_>, ) -> impl Future>, WeaverError>> + where + Self: Sized, + { + async move { + let (_, first) = self.find_notebooks_for_entry(entry_uri).await?; + Ok(first) + } + } + + /// Find notebooks containing an entry, returning count and optionally the first one. + /// + /// Uses constellation backlinks to reverse lookup. Returns: + /// - total count of notebooks containing this entry + /// - The first notebook RecordId (if any exist) + fn find_notebooks_for_entry( + &self, + entry_uri: &AtUri<'_>, + ) -> impl Future>), WeaverError>> where Self: Sized, { @@ -979,12 +997,13 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { ))) })?; + // Query with limit 2 - we only need to know if there's more than 1 let query = GetBacklinksQuery { subject: Uri::At(entry_uri.clone().into_static()), source: "sh.weaver.notebook.book:.entryList[].uri".into(), cursor: None, did: vec![], - limit: 1, + limit: 2, }; let response = self @@ -1005,11 +1024,175 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { ))) })?; - Ok(output - .records - .into_iter() - .next() - .map(|r| r.into_static())) + Ok(( + output.total, + output.records.into_iter().next().map(|r| r.into_static()), + )) + } + } + + /// Fetch an entry directly by its rkey, returning the EntryView and raw Entry. + /// + /// This bypasses notebook context entirely - useful for standalone entries + /// or when you have the rkey but not the notebook. + fn fetch_entry_by_rkey( + &self, + ident: &jacquard::types::ident::AtIdentifier<'_>, + rkey: &str, + ) -> impl Future, entry::Entry<'static>), WeaverError>> + where + Self: Sized, + { + async move { + use jacquard::to_data; + use jacquard::types::collection::Collection; + use weaver_api::com_atproto::repo::get_record::GetRecord; + + // Resolve DID and PDS from ident + let (repo_did, pds_url) = match ident { + jacquard::types::ident::AtIdentifier::Did(did) => { + let pds = self.pds_for_did(did).await.map_err(|e| { + AgentError::from( + ClientError::from(e).with_context("Failed to resolve PDS for DID"), + ) + })?; + (did.clone(), pds) + } + jacquard::types::ident::AtIdentifier::Handle(handle) => { + self.pds_for_handle(handle).await.map_err(|e| { + AgentError::from( + ClientError::from(e).with_context("Failed to resolve handle"), + ) + })? + } + }; + + // Fetch the entry record + let request = GetRecord::new() + .repo(jacquard::types::ident::AtIdentifier::Did(repo_did.clone())) + .collection(jacquard::types::nsid::Nsid::raw(entry::Entry::NSID)) + .rkey(RecordKey::any(rkey)?) + .build(); + + let response: Response = { + let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) + .map_err(|e| AgentError::from(ClientError::transport(e)))?; + + let http_response = self + .send_http(http_request) + .await + .map_err(|e| AgentError::from(ClientError::transport(e)))?; + + xrpc::process_response(http_response) + } + .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?; + + let record = response.into_output().map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Failed to parse entry record: {}", + e + ))) + })?; + + // Parse the entry value + let entry_value: entry::Entry = jacquard::from_data(&record.value).map_err(|e| { + AgentError::from(ClientError::invalid_request(format!( + "Failed to deserialize entry: {}", + e + ))) + })?; + + // Build EntryView - without notebook authors, just the entry author + let mut authors = Vec::new(); + let (profile_uri, profile_view) = self.hydrate_profile_view(&repo_did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(0) + .build(), + ); + + let entry_view = EntryView::new() + .cid(record.cid.ok_or_else(|| { + AgentError::from(ClientError::invalid_request("Entry missing CID")) + })?) + .uri(record.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .record(to_data(&entry_value).map_err(|_| { + AgentError::from(ClientError::invalid_request("Failed to serialize entry")) + })?) + .maybe_tags(entry_value.tags.clone()) + .title(entry_value.title.clone()) + .path(entry_value.path.clone()) + .authors(authors) + .build() + .into_static(); + + Ok((entry_view, entry_value.into_static())) + } + } + + /// Find an entry's index within a notebook by rkey. + /// + /// Scans the notebook's entry_list comparing rkeys extracted from URIs. + /// When found, builds BookEntryView with prev/next navigation. + fn entry_in_notebook_by_rkey<'a>( + &self, + notebook: &NotebookView<'a>, + entries: &[StrongRef<'_>], + rkey: &str, + ) -> impl Future>, WeaverError>> { + async move { + use weaver_api::sh_weaver::notebook::BookEntryRef; + + // Find the entry index by comparing rkeys + let mut found_index = None; + for (index, entry_ref) in entries.iter().enumerate() { + // Extract rkey from the entry URI + if let Ok(uri) = AtUri::new(entry_ref.uri.as_ref()) { + if let Some(entry_rkey) = uri.rkey() { + if entry_rkey.0.as_str() == rkey { + found_index = Some(index); + break; + } + } + } + } + + let index = match found_index { + Some(i) => i, + None => return Ok(None), + }; + + // Build BookEntryView with prev/next navigation + let entry_ref = &entries[index]; + let entry = self.fetch_entry_view(notebook, entry_ref).await?; + + let prev_entry = if index > 0 { + let prev_entry_ref = &entries[index - 1]; + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + let next_entry = if index < entries.len() - 1 { + let next_entry_ref = &entries[index + 1]; + self.fetch_entry_view(notebook, next_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + Ok(Some( + BookEntryView::new() + .entry(entry) + .maybe_next(next_entry) + .maybe_prev(prev_entry) + .index(index as i64) + .build(), + )) } } }