···44use crate::blobcache::BlobCache;
55use crate::{
66 components::avatar::{Avatar, AvatarFallback, AvatarImage},
77+ data::use_handle,
78 fetch,
89};
910···1112use dioxus::prelude::*;
12131314const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css");
1414-#[cfg(feature = "fullstack-server")]
1515-use dioxus::{fullstack::extract::Extension, CapturedError};
1515+1616use jacquard::prelude::*;
1717#[allow(unused_imports)]
1818use jacquard::smol_str::ToSmolStr;
···3030pub fn Entry(ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr) -> Element {
3131 let ident_clone = ident.clone();
3232 let book_title_clone = book_title.clone();
3333- let entry = use_resource(use_reactive!(|(ident, book_title, title)| async move {
3434- let fetcher = use_context::<fetch::CachedFetcher>();
3535- let entry = fetcher
3636- .get_entry(ident.clone(), book_title.clone(), title)
3737- .await
3838- .ok()
3939- .flatten();
4040- if let Some(entry_data) = &entry {
4141- let entry_record = &entry_data.1;
3333+3434+ // Use feature-gated hook for SSR support
3535+ let entry = crate::data::use_entry_data(ident.clone(), book_title.clone(), title.clone())?;
3636+3737+ // Handle blob caching when entry data is available
3838+ use_effect(move || {
3939+ if let Some((_book_entry_view, entry_record)) = &*entry.read() {
4240 if let Some(embeds) = &entry_record.embeds {
4341 if let Some(images) = &embeds.images {
4442 // Register blob mappings with service worker (client-side only)
···4846 not(feature = "fullstack-server")
4947 ))]
5048 {
5151- let _ = crate::service_worker::register_entry_blobs(
5252- &ident,
5353- book_title.as_str(),
5454- images,
5555- &fetcher,
5656- )
5757- .await;
4949+ let ident = ident.clone();
5050+ let book_title = book_title.clone();
5151+ let images = images.clone();
5252+ spawn(async move {
5353+ let fetcher = use_context::<fetch::CachedFetcher>();
5454+ let _ = crate::service_worker::register_entry_blobs(
5555+ &ident,
5656+ book_title.as_str(),
5757+ &images,
5858+ &fetcher,
5959+ )
6060+ .await;
6161+ });
5862 }
5963 #[cfg(feature = "fullstack-server")]
6064 {
6161- for image in &images.images {
6262- let cid = image.image.blob().cid();
6363- cache_blob(
6464- ident.to_smolstr(),
6565- cid.to_smolstr(),
6666- image.name.as_ref().map(|n| n.to_smolstr()),
6767- )
6868- .await
6969- .ok();
7070- }
6565+ let ident = ident.clone();
6666+ let images = images.clone();
6767+ spawn(async move {
6868+ for image in &images.images {
6969+ use crate::data::cache_blob;
7070+7171+ let cid = image.image.blob().cid();
7272+ cache_blob(
7373+ ident.to_smolstr(),
7474+ cid.to_smolstr(),
7575+ image.name.as_ref().map(|n| n.to_smolstr()),
7676+ )
7777+ .await
7878+ .ok();
7979+ }
8080+ });
7181 }
7282 }
7383 }
7484 }
7575- entry
7676- }));
8585+ });
77867887 match &*entry.read_unchecked() {
7979- Some(Some(entry_data)) => {
8888+ Some((book_entry_view, entry_record)) => {
8089 rsx! { EntryPage {
8181- book_entry_view: entry_data.0.clone(),
8282- entry_record: entry_data.1.clone(),
8383- ident: ident_clone,
9090+ book_entry_view: book_entry_view.clone(),
9191+ entry_record: entry_record.clone(),
9292+ ident: use_handle(ident_clone)?(),
8493 book_title: book_title_clone
8594 } }
8695 }
8787- Some(None) => {
8888- rsx! { div { class: "error", "Entry not found" } }
8989- }
9090- None => rsx! { p { "Loading..." } },
9696+ _ => rsx! { p { "Loading..." } },
9197 }
9298}
9399···170176 .map(|t| t.as_ref())
171177 .unwrap_or("Untitled");
172178173173- let ident = entry_view.uri.authority().clone().into_static();
174174- let ident_for_avatar = ident.clone();
179179+ let ident = use_handle(entry_view.uri.authority().clone().into_static())?;
175180176181 // Format date
177182 let formatted_date = entry_view
···195200 div { class: "entry-card",
196201 Link {
197202 to: Route::Entry {
198198- ident: ident.clone(),
203203+ ident: ident(),
199204 book_title: book_title.clone(),
200205 title: title.to_string().into()
201206 },
···214219 if let Some(author) = first_author {
215220 div { class: "entry-card-author",
216221 {
217217- match from_data::<Profile>(author.record.get_at_path(".value").unwrap()) {
218218- Ok(profile) => {
222222+ match author.record.get_at_path(".value").and_then(|v| from_data::<Profile>(v).ok()) {
223223+ Some(profile) => {
219224 let avatar = profile.avatar
220225 .map(|avatar| {
221226 let cid = avatar.blob().cid();
222222- format!("https://cdn.bsky.app/img/avatar/plain/{}/{cid}@jpeg", ident_for_avatar.as_ref())
227227+ format!("https://cdn.bsky.app/img/avatar/plain/{}/{cid}@jpeg", entry_view.uri.authority().as_ref())
223228 });
224229 let display_name = profile.display_name
225230 .as_ref()
···235240 span { class: "meta-label", "@{ident}" }
236241 }
237242 }
238238- Err(_) => {
243243+ None => {
239244 rsx! {
240245 span { class: "author-name", "Author {author.index}" }
241246 }
···296301 if i > 0 { span { ", " } }
297302 {
298303 // Parse author profile from the nested value field
299299- match from_data::<Profile>(author.record.get_at_path(".value").unwrap()) {
300300- Ok(profile) => {
304304+ match author.record.get_at_path(".value").and_then(|v| from_data::<Profile>(v).ok()) {
305305+ Some(profile) => {
301306 let avatar = profile.avatar
302307 .map(|avatar| {
303308 let cid = avatar.blob().cid();
···325330 }
326331 }
327332 }
328328- Err(_) => {
333333+ None => {
329334 rsx! {
330335 span { class: "author-name", "Author {author.index}" }
331336 }
···405410 id: Signal<String>,
406411 #[props(default)]
407412 class: Signal<String>,
408408-409413 content: ReadSignal<entry::Entry<'static>>,
414414+ ident: ReadSignal<AtIdentifier<'static>>,
410415}
411416412417/// Render some text as markdown.
413418#[allow(unused)]
414419pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element {
415415- let content = &*props.content.read();
416416- let parser = markdown_weaver::Parser::new(&content.content);
417417-418418- let mut html_buf = String::new();
419419- markdown_weaver::html::push_html(&mut html_buf, parser);
420420+ let processed = crate::data::use_rendered_markdown(
421421+ props.content.read().clone(),
422422+ props.ident.read().clone(),
423423+ )?;
420424421421- rsx! {
422422- div {
423423- id: "{&*props.id.read()}",
424424- class: "{&*props.class.read()}",
425425- dangerous_inner_html: "{html_buf}"
426426- }
425425+ match &*processed.read_unchecked() {
426426+ Some(Some(html_buf)) => rsx! {
427427+ div {
428428+ id: "{&*props.id.read()}",
429429+ class: "{&*props.class.read()}",
430430+ dangerous_inner_html: "{html_buf}"
431431+ }
432432+ },
433433+ _ => rsx! {
434434+ div {
435435+ id: "{&*props.id.read()}",
436436+ class: "{&*props.class.read()}",
437437+ "Loading..."
438438+ }
439439+ },
427440 }
428441}
429442···435448 content: entry::Entry<'static>,
436449 ident: AtIdentifier<'static>,
437450) -> Element {
438438- use n0_future::stream::StreamExt;
439439- use weaver_renderer::{
440440- atproto::{ClientContext, ClientWriter},
441441- ContextIterator, NotebookProcessor,
442442- };
443443-444444- let processed = use_resource(use_reactive!(|(content, ident)| async move {
445445- // Create client context for link/image/embed handling
446446- let fetcher = use_context::<fetch::CachedFetcher>();
447447- let did = match ident {
448448- AtIdentifier::Did(d) => d,
449449- AtIdentifier::Handle(h) => fetcher.client.resolve_handle(&h).await.ok()?,
450450- };
451451- let ctx = ClientContext::<()>::new(content.clone(), did);
452452- let parser = markdown_weaver::Parser::new(&content.content);
453453- let iter = ContextIterator::default(parser);
454454- let processor = NotebookProcessor::new(ctx, iter);
455455-456456- // Collect events from the processor stream
457457- let events: Vec<_> = StreamExt::collect(processor).await;
458458-459459- // Render to HTML
460460- let mut html_buf = String::new();
461461- let _ = ClientWriter::<_, _, ()>::new(events.into_iter(), &mut html_buf).run();
462462- Some(html_buf)
463463- }));
451451+ // Use feature-gated hook for SSR support
452452+ let processed = crate::data::use_rendered_markdown(content, ident)?;
464453465454 match &*processed.read_unchecked() {
466455 Some(Some(html_buf)) => rsx! {
···479468 },
480469 }
481470}
482482-483483-#[cfg(feature = "fullstack-server")]
484484-#[put("/cache/{ident}/{cid}?name", cache: Extension<Arc<BlobCache>>)]
485485-pub async fn cache_blob(ident: SmolStr, cid: SmolStr, name: Option<SmolStr>) -> Result<()> {
486486- let ident = AtIdentifier::new_owned(ident)?;
487487- let cid = Cid::new_owned(cid.as_bytes())?;
488488- cache.cache(ident, cid, name).await
489489-}
+245
crates/weaver-app/src/data.rs
···11+//! Feature-gated data fetching layer that abstracts over SSR and client-only modes.
22+//!
33+//! In fullstack-server mode, hooks use `use_server_future` with inline async closures.
44+//! In client-only mode, hooks use `use_resource` with context-provided fetchers.
55+66+#[cfg(feature = "server")]
77+use crate::blobcache::BlobCache;
88+use dioxus::prelude::*;
99+#[cfg(feature = "fullstack-server")]
1010+#[allow(unused_imports)]
1111+use dioxus::{fullstack::extract::Extension, CapturedError};
1212+use jacquard::types::{did::Did, string::Handle};
1313+#[allow(unused_imports)]
1414+use jacquard::{
1515+ prelude::IdentityResolver,
1616+ smol_str::SmolStr,
1717+ types::{cid::Cid, string::AtIdentifier},
1818+};
1919+#[allow(unused_imports)]
2020+use std::sync::Arc;
2121+use weaver_api::sh_weaver::notebook::{entry::Entry, BookEntryView};
2222+// ============================================================================
2323+// Wrapper Hooks (feature-gated)
2424+// ============================================================================
2525+2626+/// Fetches entry data with SSR support in fullstack mode.
2727+/// Returns a MappedSignal over the server future resource.
2828+#[cfg(feature = "fullstack-server")]
2929+pub fn use_entry_data(
3030+ ident: AtIdentifier<'static>,
3131+ book_title: SmolStr,
3232+ title: SmolStr,
3333+) -> Result<Memo<Option<(BookEntryView<'static>, Entry<'static>)>>, RenderError> {
3434+ let fetcher = use_context::<crate::fetch::CachedFetcher>();
3535+ let fetcher = fetcher.clone();
3636+ let ident = use_signal(|| ident);
3737+ let book_title = use_signal(|| book_title);
3838+ let entry_title = use_signal(|| title);
3939+ let res = use_server_future(move || {
4040+ let fetcher = fetcher.clone();
4141+ async move {
4242+ if let Some(entry) = fetcher
4343+ .get_entry(ident(), book_title(), entry_title())
4444+ .await
4545+ .ok()
4646+ .flatten()
4747+ {
4848+ let (_book_entry_view, entry_record) = (&entry.0, &entry.1);
4949+ if let Some(embeds) = &entry_record.embeds {
5050+ if let Some(images) = &embeds.images {
5151+ let ident = ident.clone();
5252+ let images = images.clone();
5353+ for image in &images.images {
5454+ use jacquard::smol_str::ToSmolStr;
5555+5656+ let cid = image.image.blob().cid();
5757+ cache_blob(
5858+ ident.to_smolstr(),
5959+ cid.to_smolstr(),
6060+ image.name.as_ref().map(|n| n.to_smolstr()),
6161+ )
6262+ .await
6363+ .ok();
6464+ }
6565+ }
6666+ }
6767+ Some((
6868+ serde_json::to_value(entry.0.clone()).unwrap(),
6969+ serde_json::to_value(entry.1.clone()).unwrap(),
7070+ ))
7171+ } else {
7272+ None
7373+ }
7474+ }
7575+ });
7676+ res.map(|r| {
7777+ use_memo(move || {
7878+ if let Some(Some((ev, e))) = &*r.read_unchecked() {
7979+ use jacquard::from_json_value;
8080+8181+ let book_entry = from_json_value::<BookEntryView>(ev.clone()).unwrap();
8282+ let entry = from_json_value::<Entry>(e.clone()).unwrap();
8383+8484+ Some((book_entry, entry))
8585+ } else {
8686+ None
8787+ }
8888+ })
8989+ })
9090+}
9191+9292+/// Fetches entry data client-side only (no SSR).
9393+#[cfg(not(feature = "fullstack-server"))]
9494+pub fn use_entry_data(
9595+ ident: AtIdentifier<'static>,
9696+ book_title: SmolStr,
9797+ title: SmolStr,
9898+) -> Result<Memo<Option<(BookEntryView<'static>, Entry<'static>)>>, RenderError> {
9999+ let fetcher = use_context::<crate::fetch::CachedFetcher>();
100100+ let fetcher = fetcher.clone();
101101+ let ident = use_signal(|| ident);
102102+ let book_title = use_signal(|| book_title);
103103+ let entry_title = use_signal(|| title);
104104+ let r = use_resource(move || {
105105+ let fetcher = fetcher.clone();
106106+ async move {
107107+ fetcher
108108+ .get_entry(ident(), book_title(), entry_title())
109109+ .await
110110+ .ok()
111111+ .flatten()
112112+ .map(|arc| (arc.0.clone(), arc.1.clone()))
113113+ }
114114+ });
115115+ Ok(use_memo(move || {
116116+ if let Some(Some((ev, e))) = &*r.read_unchecked() {
117117+ Some((ev.clone(), e.clone()))
118118+ } else {
119119+ None
120120+ }
121121+ }))
122122+}
123123+124124+pub fn use_handle(
125125+ ident: AtIdentifier<'static>,
126126+) -> Result<Memo<AtIdentifier<'static>>, RenderError> {
127127+ let fetcher = use_context::<crate::fetch::CachedFetcher>();
128128+ let fetcher = fetcher.clone();
129129+ let ident = use_signal(|| ident);
130130+ #[cfg(feature = "fullstack-server")]
131131+ let h_str = {
132132+ use_server_future(move || {
133133+ let fetcher = fetcher.clone();
134134+ async move {
135135+ use jacquard::smol_str::ToSmolStr;
136136+137137+ fetcher
138138+ .client
139139+ .resolve_ident_owned(&ident())
140140+ .await
141141+ .map(|doc| doc.handles().first().map(|h| h.to_smolstr()))
142142+ .ok()
143143+ .flatten()
144144+ }
145145+ })
146146+ };
147147+ #[cfg(not(feature = "fullstack-server"))]
148148+ let h_str = {
149149+ use_resource(move || {
150150+ let fetcher = fetcher.clone();
151151+ async move {
152152+ use jacquard::smol_str::ToSmolStr;
153153+154154+ fetcher
155155+ .client
156156+ .resolve_ident_owned(&ident())
157157+ .await
158158+ .map(|doc| doc.handles().first().map(|h| h.to_smolstr()))
159159+ .ok()
160160+ .flatten()
161161+ }
162162+ })
163163+ };
164164+ Ok(h_str.map(|h_str| {
165165+ use_memo(move || {
166166+ if let Some(Some(e)) = &*h_str.read_unchecked() {
167167+ use jacquard::IntoStatic;
168168+169169+ AtIdentifier::Handle(Handle::raw(&e).into_static())
170170+ } else {
171171+ ident()
172172+ }
173173+ })
174174+ })?)
175175+}
176176+177177+/// Hook to render markdown client-side only (no SSR).
178178+#[cfg(feature = "fullstack-server")]
179179+pub fn use_rendered_markdown(
180180+ content: Entry<'static>,
181181+ ident: AtIdentifier<'static>,
182182+) -> Result<Resource<Option<String>>, RenderError> {
183183+ let ident = use_signal(|| ident);
184184+ let content = use_signal(|| content);
185185+ let fetcher = use_context::<crate::fetch::CachedFetcher>();
186186+ Ok(use_server_future(move || {
187187+ let fetcher = fetcher.clone();
188188+ async move {
189189+ let did = match ident() {
190190+ AtIdentifier::Did(d) => d,
191191+ AtIdentifier::Handle(h) => fetcher.client.resolve_handle(&h).await.ok()?,
192192+ };
193193+ Some(render_markdown_impl(content(), did).await)
194194+ }
195195+ })?)
196196+}
197197+198198+/// Hook to render markdown client-side only (no SSR).
199199+#[cfg(not(feature = "fullstack-server"))]
200200+pub fn use_rendered_markdown(
201201+ content: Entry<'static>,
202202+ ident: AtIdentifier<'static>,
203203+) -> Result<Resource<Option<String>>, RenderError> {
204204+ let ident = use_signal(|| ident);
205205+ let content = use_signal(|| content);
206206+ let fetcher = use_context::<crate::fetch::CachedFetcher>();
207207+ Ok(use_resource(move || {
208208+ let fetcher = fetcher.clone();
209209+ async move {
210210+ let did = match ident() {
211211+ AtIdentifier::Did(d) => d,
212212+ AtIdentifier::Handle(h) => fetcher.client.resolve_handle(&h).await.ok()?,
213213+ };
214214+ Some(render_markdown_impl(content(), did).await)
215215+ }
216216+ }))
217217+}
218218+219219+/// Internal implementation of markdown rendering.
220220+async fn render_markdown_impl(content: Entry<'static>, did: Did<'static>) -> String {
221221+ use n0_future::stream::StreamExt;
222222+ use weaver_renderer::{
223223+ atproto::{ClientContext, ClientWriter},
224224+ ContextIterator, NotebookProcessor,
225225+ };
226226+227227+ let ctx = ClientContext::<()>::new(content.clone(), did);
228228+ let parser = markdown_weaver::Parser::new(&content.content);
229229+ let iter = ContextIterator::default(parser);
230230+ let processor = NotebookProcessor::new(ctx, iter);
231231+232232+ let events: Vec<_> = StreamExt::collect(processor).await;
233233+234234+ let mut html_buf = String::new();
235235+ let _ = ClientWriter::<_, _, ()>::new(events.into_iter(), &mut html_buf).run();
236236+ html_buf
237237+}
238238+239239+#[cfg(feature = "fullstack-server")]
240240+#[put("/cache/{ident}/{cid}?name", cache: Extension<Arc<BlobCache>>)]
241241+pub async fn cache_blob(ident: SmolStr, cid: SmolStr, name: Option<SmolStr>) -> Result<()> {
242242+ let ident = AtIdentifier::new_owned(ident)?;
243243+ let cid = Cid::new_owned(cid.as_bytes())?;
244244+ cache.cache(ident, cid, name).await
245245+}
+3-1
crates/weaver-app/src/fetch.rs
···6060 ) -> Result<Option<Arc<(BookEntryView<'static>, Entry<'static>)>>> {
6161 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? {
6262 let (notebook, entries) = result.as_ref();
6363- if let Some(entry) = cache_impl::get(&self.entry_cache, &(ident.clone(), entry_title.clone())) {
6363+ if let Some(entry) =
6464+ cache_impl::get(&self.entry_cache, &(ident.clone(), entry_title.clone()))
6565+ {
6466 Ok(Some(entry))
6567 } else {
6668 if let Some(entry) = entry_by_title(
+1
crates/weaver-app/src/main.rs
···2222mod cache_impl;
2323/// Define a components module that contains all shared components for our app.
2424mod components;
2525+mod data;
2526mod fetch;
2627mod service_worker;
2728/// Define a views module that contains the UI for all Layouts and Routes for our app.