atproto blogging
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fixed not defaulting to path slug, as well as weird case of trailing punct it title not matching

Orual b73b1f86 ef7507ec

+686 -211
+1
Cargo.lock
··· 4786 4786 "jacquard-lexicon", 4787 4787 "miette 7.6.0", 4788 4788 "mini-moka 0.10.99", 4789 + "n0-future", 4789 4790 "percent-encoding", 4790 4791 "reqwest", 4791 4792 "serde",
+8
crates/weaver-api/lexicons/sh_weaver_notebook_defs.json
··· 101 101 "type": "string", 102 102 "format": "datetime" 103 103 }, 104 + "path": { 105 + "type": "ref", 106 + "ref": "#path" 107 + }, 104 108 "record": { 105 109 "type": "unknown" 106 110 }, ··· 146 150 "indexedAt": { 147 151 "type": "string", 148 152 "format": "datetime" 153 + }, 154 + "path": { 155 + "type": "ref", 156 + "ref": "#path" 149 157 }, 150 158 "record": { 151 159 "type": "unknown"
+108 -34
crates/weaver-api/src/sh_weaver/notebook.rs
··· 448 448 }), 449 449 ); 450 450 map.insert( 451 + ::jacquard_common::smol_str::SmolStr::new_static("path"), 452 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 453 + description: None, 454 + r#ref: ::jacquard_common::CowStr::new_static("#path"), 455 + }), 456 + ); 457 + map.insert( 451 458 ::jacquard_common::smol_str::SmolStr::new_static("record"), 452 459 ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { 453 460 description: None, ··· 564 571 r#enum: None, 565 572 r#const: None, 566 573 known_values: None, 574 + }), 575 + ); 576 + map.insert( 577 + ::jacquard_common::smol_str::SmolStr::new_static("path"), 578 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 579 + description: None, 580 + r#ref: ::jacquard_common::CowStr::new_static("#path"), 567 581 }), 568 582 ); 569 583 map.insert( ··· 1139 1153 #[serde(borrow)] 1140 1154 pub cid: jacquard_common::types::string::Cid<'a>, 1141 1155 pub indexed_at: jacquard_common::types::string::Datetime, 1156 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 1157 + #[serde(borrow)] 1158 + pub path: std::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1142 1159 #[serde(borrow)] 1143 1160 pub record: jacquard_common::types::value::Data<'a>, 1144 1161 #[serde(skip_serializing_if = "std::option::Option::is_none")] ··· 1253 1270 ::core::option::Option<Vec<crate::sh_weaver::notebook::AuthorListView<'a>>>, 1254 1271 ::core::option::Option<jacquard_common::types::string::Cid<'a>>, 1255 1272 ::core::option::Option<jacquard_common::types::string::Datetime>, 1273 + ::core::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1256 1274 ::core::option::Option<jacquard_common::types::value::Data<'a>>, 1257 1275 ::core::option::Option<crate::sh_weaver::notebook::RenderedView<'a>>, 1258 1276 ::core::option::Option<crate::sh_weaver::notebook::Tags<'a>>, ··· 1274 1292 pub fn new() -> Self { 1275 1293 EntryViewBuilder { 1276 1294 _phantom_state: ::core::marker::PhantomData, 1277 - __unsafe_private_named: (None, None, None, None, None, None, None, None), 1295 + __unsafe_private_named: ( 1296 + None, 1297 + None, 1298 + None, 1299 + None, 1300 + None, 1301 + None, 1302 + None, 1303 + None, 1304 + None, 1305 + ), 1278 1306 _phantom: ::core::marker::PhantomData, 1279 1307 } 1280 1308 } ··· 1337 1365 } 1338 1366 } 1339 1367 1368 + impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { 1369 + /// Set the `path` field (optional) 1370 + pub fn path( 1371 + mut self, 1372 + value: impl Into<Option<crate::sh_weaver::notebook::Path<'a>>>, 1373 + ) -> Self { 1374 + self.__unsafe_private_named.3 = value.into(); 1375 + self 1376 + } 1377 + /// Set the `path` field to an Option value (optional) 1378 + pub fn maybe_path( 1379 + mut self, 1380 + value: Option<crate::sh_weaver::notebook::Path<'a>>, 1381 + ) -> Self { 1382 + self.__unsafe_private_named.3 = value; 1383 + self 1384 + } 1385 + } 1386 + 1340 1387 impl<'a, S> EntryViewBuilder<'a, S> 1341 1388 where 1342 1389 S: entry_view_state::State, ··· 1347 1394 mut self, 1348 1395 value: impl Into<jacquard_common::types::value::Data<'a>>, 1349 1396 ) -> EntryViewBuilder<'a, entry_view_state::SetRecord<S>> { 1350 - self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); 1397 + self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); 1351 1398 EntryViewBuilder { 1352 1399 _phantom_state: ::core::marker::PhantomData, 1353 1400 __unsafe_private_named: self.__unsafe_private_named, ··· 1362 1409 mut self, 1363 1410 value: impl Into<Option<crate::sh_weaver::notebook::RenderedView<'a>>>, 1364 1411 ) -> Self { 1365 - self.__unsafe_private_named.4 = value.into(); 1412 + self.__unsafe_private_named.5 = value.into(); 1366 1413 self 1367 1414 } 1368 1415 /// Set the `renderedView` field to an Option value (optional) ··· 1370 1417 mut self, 1371 1418 value: Option<crate::sh_weaver::notebook::RenderedView<'a>>, 1372 1419 ) -> Self { 1373 - self.__unsafe_private_named.4 = value; 1420 + self.__unsafe_private_named.5 = value; 1374 1421 self 1375 1422 } 1376 1423 } ··· 1381 1428 mut self, 1382 1429 value: impl Into<Option<crate::sh_weaver::notebook::Tags<'a>>>, 1383 1430 ) -> Self { 1384 - self.__unsafe_private_named.5 = value.into(); 1431 + self.__unsafe_private_named.6 = value.into(); 1385 1432 self 1386 1433 } 1387 1434 /// Set the `tags` field to an Option value (optional) ··· 1389 1436 mut self, 1390 1437 value: Option<crate::sh_weaver::notebook::Tags<'a>>, 1391 1438 ) -> Self { 1392 - self.__unsafe_private_named.5 = value; 1439 + self.__unsafe_private_named.6 = value; 1393 1440 self 1394 1441 } 1395 1442 } ··· 1400 1447 mut self, 1401 1448 value: impl Into<Option<crate::sh_weaver::notebook::Title<'a>>>, 1402 1449 ) -> Self { 1403 - self.__unsafe_private_named.6 = value.into(); 1450 + self.__unsafe_private_named.7 = value.into(); 1404 1451 self 1405 1452 } 1406 1453 /// Set the `title` field to an Option value (optional) ··· 1408 1455 mut self, 1409 1456 value: Option<crate::sh_weaver::notebook::Title<'a>>, 1410 1457 ) -> Self { 1411 - self.__unsafe_private_named.6 = value; 1458 + self.__unsafe_private_named.7 = value; 1412 1459 self 1413 1460 } 1414 1461 } ··· 1423 1470 mut self, 1424 1471 value: impl Into<jacquard_common::types::string::AtUri<'a>>, 1425 1472 ) -> EntryViewBuilder<'a, entry_view_state::SetUri<S>> { 1426 - self.__unsafe_private_named.7 = ::core::option::Option::Some(value.into()); 1473 + self.__unsafe_private_named.8 = ::core::option::Option::Some(value.into()); 1427 1474 EntryViewBuilder { 1428 1475 _phantom_state: ::core::marker::PhantomData, 1429 1476 __unsafe_private_named: self.__unsafe_private_named, ··· 1447 1494 authors: self.__unsafe_private_named.0.unwrap(), 1448 1495 cid: self.__unsafe_private_named.1.unwrap(), 1449 1496 indexed_at: self.__unsafe_private_named.2.unwrap(), 1450 - record: self.__unsafe_private_named.3.unwrap(), 1451 - rendered_view: self.__unsafe_private_named.4, 1452 - tags: self.__unsafe_private_named.5, 1453 - title: self.__unsafe_private_named.6, 1454 - uri: self.__unsafe_private_named.7.unwrap(), 1497 + path: self.__unsafe_private_named.3, 1498 + record: self.__unsafe_private_named.4.unwrap(), 1499 + rendered_view: self.__unsafe_private_named.5, 1500 + tags: self.__unsafe_private_named.6, 1501 + title: self.__unsafe_private_named.7, 1502 + uri: self.__unsafe_private_named.8.unwrap(), 1455 1503 extra_data: Default::default(), 1456 1504 } 1457 1505 } ··· 1467 1515 authors: self.__unsafe_private_named.0.unwrap(), 1468 1516 cid: self.__unsafe_private_named.1.unwrap(), 1469 1517 indexed_at: self.__unsafe_private_named.2.unwrap(), 1470 - record: self.__unsafe_private_named.3.unwrap(), 1471 - rendered_view: self.__unsafe_private_named.4, 1472 - tags: self.__unsafe_private_named.5, 1473 - title: self.__unsafe_private_named.6, 1474 - uri: self.__unsafe_private_named.7.unwrap(), 1518 + path: self.__unsafe_private_named.3, 1519 + record: self.__unsafe_private_named.4.unwrap(), 1520 + rendered_view: self.__unsafe_private_named.5, 1521 + tags: self.__unsafe_private_named.6, 1522 + title: self.__unsafe_private_named.7, 1523 + uri: self.__unsafe_private_named.8.unwrap(), 1475 1524 extra_data: Some(extra_data), 1476 1525 } 1477 1526 } ··· 1511 1560 #[serde(borrow)] 1512 1561 pub cid: jacquard_common::types::string::Cid<'a>, 1513 1562 pub indexed_at: jacquard_common::types::string::Datetime, 1563 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 1564 + #[serde(borrow)] 1565 + pub path: std::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1514 1566 #[serde(borrow)] 1515 1567 pub record: jacquard_common::types::value::Data<'a>, 1516 1568 #[serde(skip_serializing_if = "std::option::Option::is_none")] ··· 1622 1674 ::core::option::Option<Vec<crate::sh_weaver::notebook::AuthorListView<'a>>>, 1623 1675 ::core::option::Option<jacquard_common::types::string::Cid<'a>>, 1624 1676 ::core::option::Option<jacquard_common::types::string::Datetime>, 1677 + ::core::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1625 1678 ::core::option::Option<jacquard_common::types::value::Data<'a>>, 1626 1679 ::core::option::Option<crate::sh_weaver::notebook::Tags<'a>>, 1627 1680 ::core::option::Option<crate::sh_weaver::notebook::Title<'a>>, ··· 1642 1695 pub fn new() -> Self { 1643 1696 NotebookViewBuilder { 1644 1697 _phantom_state: ::core::marker::PhantomData, 1645 - __unsafe_private_named: (None, None, None, None, None, None, None), 1698 + __unsafe_private_named: (None, None, None, None, None, None, None, None), 1646 1699 _phantom: ::core::marker::PhantomData, 1647 1700 } 1648 1701 } ··· 1705 1758 } 1706 1759 } 1707 1760 1761 + impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { 1762 + /// Set the `path` field (optional) 1763 + pub fn path( 1764 + mut self, 1765 + value: impl Into<Option<crate::sh_weaver::notebook::Path<'a>>>, 1766 + ) -> Self { 1767 + self.__unsafe_private_named.3 = value.into(); 1768 + self 1769 + } 1770 + /// Set the `path` field to an Option value (optional) 1771 + pub fn maybe_path( 1772 + mut self, 1773 + value: Option<crate::sh_weaver::notebook::Path<'a>>, 1774 + ) -> Self { 1775 + self.__unsafe_private_named.3 = value; 1776 + self 1777 + } 1778 + } 1779 + 1708 1780 impl<'a, S> NotebookViewBuilder<'a, S> 1709 1781 where 1710 1782 S: notebook_view_state::State, ··· 1715 1787 mut self, 1716 1788 value: impl Into<jacquard_common::types::value::Data<'a>>, 1717 1789 ) -> NotebookViewBuilder<'a, notebook_view_state::SetRecord<S>> { 1718 - self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); 1790 + self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); 1719 1791 NotebookViewBuilder { 1720 1792 _phantom_state: ::core::marker::PhantomData, 1721 1793 __unsafe_private_named: self.__unsafe_private_named, ··· 1730 1802 mut self, 1731 1803 value: impl Into<Option<crate::sh_weaver::notebook::Tags<'a>>>, 1732 1804 ) -> Self { 1733 - self.__unsafe_private_named.4 = value.into(); 1805 + self.__unsafe_private_named.5 = value.into(); 1734 1806 self 1735 1807 } 1736 1808 /// Set the `tags` field to an Option value (optional) ··· 1738 1810 mut self, 1739 1811 value: Option<crate::sh_weaver::notebook::Tags<'a>>, 1740 1812 ) -> Self { 1741 - self.__unsafe_private_named.4 = value; 1813 + self.__unsafe_private_named.5 = value; 1742 1814 self 1743 1815 } 1744 1816 } ··· 1749 1821 mut self, 1750 1822 value: impl Into<Option<crate::sh_weaver::notebook::Title<'a>>>, 1751 1823 ) -> Self { 1752 - self.__unsafe_private_named.5 = value.into(); 1824 + self.__unsafe_private_named.6 = value.into(); 1753 1825 self 1754 1826 } 1755 1827 /// Set the `title` field to an Option value (optional) ··· 1757 1829 mut self, 1758 1830 value: Option<crate::sh_weaver::notebook::Title<'a>>, 1759 1831 ) -> Self { 1760 - self.__unsafe_private_named.5 = value; 1832 + self.__unsafe_private_named.6 = value; 1761 1833 self 1762 1834 } 1763 1835 } ··· 1772 1844 mut self, 1773 1845 value: impl Into<jacquard_common::types::string::AtUri<'a>>, 1774 1846 ) -> NotebookViewBuilder<'a, notebook_view_state::SetUri<S>> { 1775 - self.__unsafe_private_named.6 = ::core::option::Option::Some(value.into()); 1847 + self.__unsafe_private_named.7 = ::core::option::Option::Some(value.into()); 1776 1848 NotebookViewBuilder { 1777 1849 _phantom_state: ::core::marker::PhantomData, 1778 1850 __unsafe_private_named: self.__unsafe_private_named, ··· 1796 1868 authors: self.__unsafe_private_named.0.unwrap(), 1797 1869 cid: self.__unsafe_private_named.1.unwrap(), 1798 1870 indexed_at: self.__unsafe_private_named.2.unwrap(), 1799 - record: self.__unsafe_private_named.3.unwrap(), 1800 - tags: self.__unsafe_private_named.4, 1801 - title: self.__unsafe_private_named.5, 1802 - uri: self.__unsafe_private_named.6.unwrap(), 1871 + path: self.__unsafe_private_named.3, 1872 + record: self.__unsafe_private_named.4.unwrap(), 1873 + tags: self.__unsafe_private_named.5, 1874 + title: self.__unsafe_private_named.6, 1875 + uri: self.__unsafe_private_named.7.unwrap(), 1803 1876 extra_data: Default::default(), 1804 1877 } 1805 1878 } ··· 1815 1888 authors: self.__unsafe_private_named.0.unwrap(), 1816 1889 cid: self.__unsafe_private_named.1.unwrap(), 1817 1890 indexed_at: self.__unsafe_private_named.2.unwrap(), 1818 - record: self.__unsafe_private_named.3.unwrap(), 1819 - tags: self.__unsafe_private_named.4, 1820 - title: self.__unsafe_private_named.5, 1821 - uri: self.__unsafe_private_named.6.unwrap(), 1891 + path: self.__unsafe_private_named.3, 1892 + record: self.__unsafe_private_named.4.unwrap(), 1893 + tags: self.__unsafe_private_named.5, 1894 + title: self.__unsafe_private_named.6, 1895 + uri: self.__unsafe_private_named.7.unwrap(), 1822 1896 extra_data: Some(extra_data), 1823 1897 } 1824 1898 }
+1 -1
crates/weaver-app/Cargo.toml
··· 11 11 wasm-split = ["dioxus/wasm-split"] 12 12 no-app-index = [] 13 13 14 - web = ["dioxus/web"] 14 + web = ["dioxus/web", "dioxus-primitives/web"] 15 15 desktop = ["dioxus/desktop"] 16 16 mobile = ["dioxus/mobile"] 17 17 server = [ "dioxus/server", "dep:jacquard-axum", "dep:axum"]
+45 -36
crates/weaver-app/src/auth/mod.rs
··· 1 1 mod storage; 2 - use dioxus::CapturedError; 3 2 pub use storage::AuthStore; 4 3 5 4 mod state; ··· 10 9 #[cfg(all(feature = "fullstack-server", feature = "server"))] 11 10 use jacquard::oauth::types::OAuthClientMetadata; 12 11 12 + /// Result of attempting to restore a session 13 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 14 + pub enum RestoreResult { 15 + /// Session was successfully restored 16 + Restored, 17 + /// No saved session was found 18 + NoSession, 19 + /// Session was found but expired/invalid and has been cleared 20 + SessionExpired, 21 + } 22 + 13 23 #[cfg(all(feature = "fullstack-server", feature = "server"))] 14 24 #[get("/oauth-client-metadata.json")] 15 25 pub async fn client_metadata() -> Result<axum::Json<serde_json::Value>> { ··· 25 35 } 26 36 27 37 #[cfg(not(target_arch = "wasm32"))] 28 - pub async fn restore_session( 29 - _fetcher: Fetcher, 30 - _auth_state: Signal<AuthState>, 31 - ) -> Result<(), String> { 32 - Ok(()) 38 + pub async fn restore_session(_fetcher: Fetcher, _auth_state: Signal<AuthState>) -> RestoreResult { 39 + RestoreResult::NoSession 33 40 } 34 41 35 42 #[cfg(target_arch = "wasm32")] 36 - pub async fn restore_session( 37 - fetcher: Fetcher, 38 - mut auth_state: Signal<AuthState>, 39 - ) -> Result<(), CapturedError> { 40 - use dioxus::prelude::*; 43 + pub async fn restore_session(fetcher: Fetcher, mut auth_state: Signal<AuthState>) -> RestoreResult { 41 44 use gloo_storage::{LocalStorage, Storage}; 45 + use jacquard::oauth::authstore::ClientAuthStore; 42 46 use jacquard::types::string::Did; 47 + 43 48 // Look for session keys in localStorage (format: oauth_session_{did}_{session_id}) 44 - let keys = LocalStorage::get_all::<serde_json::Value>()?; 45 - let mut found_session: Option<(String, String)> = None; 49 + let Ok(keys) = LocalStorage::get_all::<serde_json::Value>() else { 50 + return RestoreResult::NoSession; 51 + }; 52 + let Some(keys) = keys.as_object() else { 53 + return RestoreResult::NoSession; 54 + }; 46 55 47 - let keys = keys 48 - .as_object() 49 - .ok_or(CapturedError::from_display(format!("{}", keys)))?; 56 + let mut found_session: Option<(String, String)> = None; 50 57 for key in keys.keys() { 51 58 if key.starts_with("oauth_session_") { 52 59 let parts: Vec<&str> = key ··· 61 68 } 62 69 } 63 70 64 - let (did_str, session_id) = 65 - found_session.ok_or(CapturedError::from_display("No saved session found"))?; 66 - let did = Did::new_owned(did_str)?; 67 - 68 - let session = fetcher 69 - .client 70 - .oauth_client 71 - .restore(&did, &session_id) 72 - .await?; 73 - 74 - // Get DID and handle from session 75 - let (restored_did, session_id) = session.session_info().await; 76 - 77 - // Update auth state 78 - auth_state 79 - .write() 80 - .set_authenticated(restored_did, session_id); 81 - fetcher.upgrade_to_authenticated(session).await; 71 + let Some((did_str, session_id)) = found_session else { 72 + return RestoreResult::NoSession; 73 + }; 74 + let Ok(did) = Did::new_owned(did_str) else { 75 + return RestoreResult::NoSession; 76 + }; 82 77 83 - tracing::debug!("session restored"); 84 - Ok(()) 78 + match fetcher.client.oauth_client.restore(&did, &session_id).await { 79 + Ok(session) => { 80 + let (restored_did, session_id) = session.session_info().await; 81 + auth_state 82 + .write() 83 + .set_authenticated(restored_did, session_id); 84 + fetcher.upgrade_to_authenticated(session).await; 85 + tracing::debug!("session restored"); 86 + RestoreResult::Restored 87 + } 88 + Err(e) => { 89 + tracing::warn!("Session restore failed, clearing dead session: {e}"); 90 + let _ = AuthStore::new().delete_session(&did, &session_id).await; 91 + RestoreResult::SessionExpired 92 + } 93 + } 85 94 }
+34 -4
crates/weaver-app/src/components/entry.rs
··· 62 62 #[cfg(feature = "fullstack-server")] 63 63 use_effect(use_reactive!(|route| { 64 64 if route != last_route() { 65 + tracing::debug!("[EntryPage] route changed, restarting resource"); 65 66 entry_res.restart(); 66 67 last_route.set(route.clone()); 67 68 } 68 69 })); 69 70 71 + // Debug: log route params and entry state 72 + tracing::debug!( 73 + "[EntryPage] route params: ident={:?}, book_title={:?}, title={:?}", 74 + ident(), 75 + book_title(), 76 + title() 77 + ); 78 + tracing::debug!("[EntryPage] rendering, entry.is_some={}", entry.read().is_some()); 79 + 70 80 // Handle blob caching when entry data is available 71 - match &*entry.read_unchecked() { 81 + // Use read() instead of read_unchecked() for proper reactive tracking 82 + match &*entry.read() { 72 83 Some((book_entry_view, entry_record)) => { 73 84 if let Some(embeds) = &entry_record.embeds { 74 85 if let Some(_images) = &embeds.images { ··· 202 213 .as_ref() 203 214 .map(|t| t.as_ref()) 204 215 .unwrap_or("Untitled"); 216 + 217 + // Get path from view for URL, fallback to title 218 + let entry_path = entry_view 219 + .path 220 + .as_ref() 221 + .map(|p| p.as_ref().to_string()) 222 + .unwrap_or_else(|| title.to_string()); 223 + 224 + // Parse entry record for content preview 225 + let parsed_entry = from_data::<Entry>(&entry_view.record).ok(); 226 + 205 227 // Format date 206 228 let formatted_date = entry_view 207 229 .indexed_at ··· 229 251 }; 230 252 231 253 // Render preview from entry content 232 - let preview_html = from_data::<Entry>(&entry_view.record).ok().map(|entry| { 254 + let preview_html = parsed_entry.as_ref().map(|entry| { 233 255 let parser = markdown_weaver::Parser::new(&entry.content); 234 256 let mut html_buf = String::new(); 235 257 markdown_weaver::html::push_html(&mut html_buf, parser); ··· 244 266 to: Route::EntryPage { 245 267 ident: ident.clone(), 246 268 book_title: book_title.clone(), 247 - title: title.to_string().into() 269 + title: entry_path.clone().into() 248 270 }, 249 271 class: "entry-card-title-link", 250 272 h3 { class: "entry-card-title", "{title}" } ··· 477 499 .as_ref() 478 500 .map(|t| t.as_ref()) 479 501 .unwrap_or("Untitled"); 502 + 503 + // Get path from view for URL, fallback to title 504 + let entry_path = entry 505 + .path 506 + .as_ref() 507 + .map(|p| p.as_ref().to_string()) 508 + .unwrap_or_else(|| entry_title.to_string()); 509 + 480 510 let arrow = if direction == "prev" { "←" } else { "→" }; 481 511 482 512 rsx! { ··· 484 514 to: Route::EntryPage { 485 515 ident: ident.clone(), 486 516 book_title: book_title.clone(), 487 - title: entry_title.to_string().into() 517 + title: entry_path.into() 488 518 }, 489 519 class: "nav-button nav-button-{direction}", 490 520 div { class: "nav-arrow", "{arrow}" }
+50 -15
crates/weaver-app/src/components/identity.rs
··· 89 89 notebook: NotebookView<'static>, 90 90 entry_refs: Vec<StrongRef<'static>>, 91 91 ) -> Element { 92 - use jacquard::IntoStatic; 92 + use jacquard::{from_data, IntoStatic}; 93 + use weaver_api::sh_weaver::notebook::book::Book; 93 94 94 95 let fetcher = use_context::<fetch::Fetcher>(); 95 96 let auth_state = use_context::<Signal<AuthState>>(); ··· 100 101 .map(|t| t.as_ref()) 101 102 .unwrap_or("Untitled Notebook"); 102 103 104 + // Get notebook path for URLs, fallback to title 105 + let notebook_path = notebook 106 + .path 107 + .as_ref() 108 + .map(|p| p.as_ref().to_string()) 109 + .unwrap_or_else(|| title.to_string()); 110 + 103 111 // Check ownership for "Add Entry" link 104 112 let notebook_ident = notebook.uri.authority().clone().into_static(); 105 113 let is_owner = { ··· 117 125 let show_authors = notebook.authors.len() > 1; 118 126 119 127 let ident = notebook.uri.authority().clone().into_static(); 120 - let book_title: SmolStr = title.to_string().into(); 128 + let book_title: SmolStr = notebook_path.clone().into(); 121 129 122 130 // Fetch all entries to get first/last 123 131 let ident_for_fetch = ident.clone(); ··· 139 147 Link { 140 148 to: Route::EntryPage { 141 149 ident: ident.clone(), 142 - book_title: title.to_string().into(), 150 + book_title: notebook_path.clone().into(), 143 151 title: "".into() // Will redirect to first entry 144 152 }, 145 153 class: "notebook-card-header-link", ··· 217 225 .map(|t| t.as_ref()) 218 226 .unwrap_or("Untitled"); 219 227 220 - let preview_html = from_data::<Entry>(&entry_view.entry.record).ok().map(|entry| { 228 + // Get path from view, fallback to title 229 + let entry_path = entry_view.entry.path 230 + .as_ref() 231 + .map(|p| p.as_ref().to_string()) 232 + .unwrap_or_else(|| entry_title.to_string()); 233 + 234 + // Parse entry for created_at and preview 235 + let parsed_entry = from_data::<Entry>(&entry_view.entry.record).ok(); 236 + 237 + let preview_html = parsed_entry.as_ref().map(|entry| { 221 238 let parser = markdown_weaver::Parser::new(&entry.content); 222 239 let mut html_buf = String::new(); 223 240 markdown_weaver::html::push_html(&mut html_buf, parser); 224 241 html_buf 225 242 }); 226 243 227 - let created_at = from_data::<Entry>(&entry_view.entry.record).ok() 244 + let created_at = parsed_entry.as_ref() 228 245 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 229 246 230 247 let entry_uri = entry_view.entry.uri.clone().into_static(); ··· 236 253 to: Route::EntryPage { 237 254 ident: ident.clone(), 238 255 book_title: book_title.clone(), 239 - title: entry_title.to_string().into() 256 + title: entry_path.clone().into() 240 257 }, 241 258 class: "entry-preview-title-link", 242 259 div { class: "entry-preview-title", "{entry_title}" } ··· 258 275 to: Route::EntryPage { 259 276 ident: ident.clone(), 260 277 book_title: book_title.clone(), 261 - title: entry_title.to_string().into() 278 + title: entry_path.clone().into() 262 279 }, 263 280 class: "entry-preview-content-link", 264 281 div { class: "entry-preview-content", dangerous_inner_html: "{html}" } ··· 278 295 .map(|t| t.as_ref()) 279 296 .unwrap_or("Untitled"); 280 297 281 - let preview_html = from_data::<Entry>(&first_entry.entry.record).ok().map(|entry| { 298 + // Get path from view, fallback to title 299 + let entry_path = first_entry.entry.path 300 + .as_ref() 301 + .map(|p| p.as_ref().to_string()) 302 + .unwrap_or_else(|| entry_title.to_string()); 303 + 304 + // Parse entry for created_at and preview 305 + let parsed_entry = from_data::<Entry>(&first_entry.entry.record).ok(); 306 + 307 + let preview_html = parsed_entry.as_ref().map(|entry| { 282 308 let parser = markdown_weaver::Parser::new(&entry.content); 283 309 let mut html_buf = String::new(); 284 310 markdown_weaver::html::push_html(&mut html_buf, parser); 285 311 html_buf 286 312 }); 287 313 288 - let created_at = from_data::<Entry>(&first_entry.entry.record).ok() 314 + let created_at = parsed_entry.as_ref() 289 315 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 290 316 291 317 let entry_uri = first_entry.entry.uri.clone().into_static(); ··· 297 323 to: Route::EntryPage { 298 324 ident: ident.clone(), 299 325 book_title: book_title.clone(), 300 - title: entry_title.to_string().into() 326 + title: entry_path.clone().into() 301 327 }, 302 328 class: "entry-preview-title-link", 303 329 div { class: "entry-preview-title", "{entry_title}" } ··· 319 345 to: Route::EntryPage { 320 346 ident: ident.clone(), 321 347 book_title: book_title.clone(), 322 - title: entry_title.to_string().into() 348 + title: entry_path.clone().into() 323 349 }, 324 350 class: "entry-preview-content-link", 325 351 div { class: "entry-preview-content", dangerous_inner_html: "{html}" } ··· 348 374 .map(|t| t.as_ref()) 349 375 .unwrap_or("Untitled"); 350 376 351 - let preview_html = from_data::<Entry>(&last_entry.entry.record).ok().map(|entry| { 377 + // Get path from view, fallback to title 378 + let entry_path = last_entry.entry.path 379 + .as_ref() 380 + .map(|p| p.as_ref().to_string()) 381 + .unwrap_or_else(|| entry_title.to_string()); 382 + 383 + // Parse entry for created_at and preview 384 + let parsed_entry = from_data::<Entry>(&last_entry.entry.record).ok(); 385 + 386 + let preview_html = parsed_entry.as_ref().map(|entry| { 352 387 let parser = markdown_weaver::Parser::new(&entry.content); 353 388 let mut html_buf = String::new(); 354 389 markdown_weaver::html::push_html(&mut html_buf, parser); 355 390 html_buf 356 391 }); 357 392 358 - let created_at = from_data::<Entry>(&last_entry.entry.record).ok() 393 + let created_at = parsed_entry.as_ref() 359 394 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 360 395 361 396 let entry_uri = last_entry.entry.uri.clone().into_static(); ··· 367 402 to: Route::EntryPage { 368 403 ident: ident.clone(), 369 404 book_title: book_title.clone(), 370 - title: entry_title.to_string().into() 405 + title: entry_path.clone().into() 371 406 }, 372 407 class: "entry-preview-title-link", 373 408 div { class: "entry-preview-title", "{entry_title}" } ··· 389 424 to: Route::EntryPage { 390 425 ident: ident.clone(), 391 426 book_title: book_title.clone(), 392 - title: entry_title.to_string().into() 427 + title: entry_path.clone().into() 393 428 }, 394 429 class: "entry-preview-content-link", 395 430 div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
+1
crates/weaver-app/src/components/mod.rs
··· 134 134 135 135 pub use entry_actions::EntryActions; 136 136 pub use profile_actions::{ProfileActions, ProfileActionsMenubar}; 137 + pub mod toast;
+15
crates/weaver-app/src/components/toast/component.rs
··· 1 + use dioxus::prelude::*; 2 + use dioxus_primitives::toast::{self, ToastProviderProps}; 3 + 4 + #[component] 5 + pub fn ToastProvider(props: ToastProviderProps) -> Element { 6 + rsx! { 7 + document::Link { rel: "stylesheet", href: asset!("./style.css") } 8 + toast::ToastProvider { 9 + default_duration: props.default_duration, 10 + max_toasts: props.max_toasts, 11 + render_toast: props.render_toast, 12 + {props.children} 13 + } 14 + } 15 + }
+2
crates/weaver-app/src/components/toast/mod.rs
··· 1 + mod component; 2 + pub use component::*;
+174
crates/weaver-app/src/components/toast/style.css
··· 1 + .toast-container { 2 + position: fixed; 3 + z-index: 9999; 4 + right: 20px; 5 + bottom: 20px; 6 + max-width: 350px; 7 + } 8 + 9 + .toast-list { 10 + display: flex; 11 + flex-direction: column-reverse; 12 + padding: 0; 13 + margin: 0; 14 + gap: 0.75rem; 15 + } 16 + 17 + .toast-item { 18 + display: flex; 19 + } 20 + 21 + .toast { 22 + z-index: calc(var(--toast-count) - var(--toast-index)); 23 + display: flex; 24 + overflow: hidden; 25 + width: 18rem; 26 + min-height: 4rem; 27 + height: auto; 28 + box-sizing: border-box; 29 + align-items: center; 30 + justify-content: space-between; 31 + padding: 12px 16px; 32 + border: 1px solid var(--color-border); 33 + margin-top: -4rem; 34 + background-color: var(--color-surface); 35 + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); 36 + opacity: calc(1 - var(--toast-hidden)); 37 + transform: scale( 38 + calc(100% - var(--toast-index) * 5%), 39 + calc(100% - var(--toast-index) * 2%) 40 + ); 41 + transition: transform 0.2s ease, margin-top 0.2s ease, opacity 0.2s ease; 42 + 43 + --toast-hidden: calc(min(max(0, var(--toast-index) - 2), 1)); 44 + } 45 + 46 + .toast-container:not(:hover, :focus-within) 47 + .toast[data-toast-even]:not([data-top]) { 48 + animation: slide-up-even 0.2s ease-out; 49 + } 50 + 51 + .toast-container:not(:hover, :focus-within) 52 + .toast[data-toast-odd]:not([data-top]) { 53 + animation: slide-up-odd 0.2s ease-out; 54 + } 55 + 56 + @keyframes slide-up-even { 57 + from { 58 + transform: translateY(0.5rem) 59 + scale( 60 + calc(100% - var(--toast-index) * 5%), 61 + calc(100% - var(--toast-index) * 2%) 62 + ); 63 + } 64 + 65 + to { 66 + transform: translateY(0) 67 + scale( 68 + calc(100% - var(--toast-index) * 5%), 69 + calc(100% - var(--toast-index) * 2%) 70 + ); 71 + } 72 + } 73 + 74 + @keyframes slide-up-odd { 75 + from { 76 + transform: translateY(0.5rem) 77 + scale( 78 + calc(100% - var(--toast-index) * 5%), 79 + calc(100% - var(--toast-index) * 2%) 80 + ); 81 + } 82 + 83 + to { 84 + transform: translateY(0) 85 + scale( 86 + calc(100% - var(--toast-index) * 5%), 87 + calc(100% - var(--toast-index) * 2%) 88 + ); 89 + } 90 + } 91 + 92 + .toast[data-top] { 93 + animation: slide-in 0.2s ease-out; 94 + } 95 + 96 + .toast-container:hover .toast[data-top], 97 + .toast-container:focus-within .toast[data-top] { 98 + animation: slide-in 0 ease-out; 99 + } 100 + 101 + @keyframes slide-in { 102 + from { 103 + opacity: 0; 104 + transform: translateY(100%) 105 + scale( 106 + calc(110% - var(--toast-index) * 5%), 107 + calc(110% - var(--toast-index) * 2%) 108 + ); 109 + } 110 + 111 + to { 112 + opacity: 1; 113 + transform: translateY(0) 114 + scale( 115 + calc(100% - var(--toast-index) * 5%), 116 + calc(100% - var(--toast-index) * 2%) 117 + ); 118 + } 119 + } 120 + 121 + .toast-container:hover .toast, 122 + .toast-container:focus-within .toast { 123 + margin-top: var(--toast-padding); 124 + opacity: 1; 125 + transform: scale(calc(100%)); 126 + } 127 + 128 + .toast[data-type="success"] { 129 + border-left: 4px solid var(--color-success); 130 + } 131 + 132 + .toast[data-type="error"] { 133 + border-left: 4px solid var(--color-error); 134 + } 135 + 136 + .toast[data-type="warning"] { 137 + border-left: 4px solid var(--color-warning); 138 + } 139 + 140 + .toast[data-type="info"] { 141 + border-left: 4px solid var(--color-secondary); 142 + } 143 + 144 + .toast-content { 145 + flex: 1; 146 + margin-right: 8px; 147 + } 148 + 149 + .toast-title { 150 + margin-bottom: 4px; 151 + color: var(--color-emphasis); 152 + font-weight: 600; 153 + } 154 + 155 + .toast-description { 156 + color: var(--color-text); 157 + font-size: 0.875rem; 158 + } 159 + 160 + .toast-close { 161 + align-self: flex-start; 162 + padding: 0; 163 + border: none; 164 + margin: 0; 165 + background: none; 166 + color: var(--color-muted); 167 + cursor: pointer; 168 + font-size: 18px; 169 + line-height: 1; 170 + } 171 + 172 + .toast-close:hover { 173 + color: var(--color-text); 174 + }
+36 -27
crates/weaver-app/src/data.rs
··· 45 45 let res = use_server_future(use_reactive!(|(ident, book_title, title)| { 46 46 let fetcher = fetcher.clone(); 47 47 async move { 48 - if let Some(entry) = fetcher 48 + let fetch_result = fetcher 49 49 .get_entry(ident(), book_title(), title()) 50 - .await 51 - .ok() 52 - .flatten() 53 - { 54 - let (_book_entry_view, entry_record) = (&entry.0, &entry.1); 55 - if let Some(embeds) = &entry_record.embeds { 56 - if let Some(images) = &embeds.images { 57 - let ident_val = ident.clone(); 58 - let images = images.clone(); 59 - for image in &images.images { 60 - use jacquard::smol_str::ToSmolStr; 50 + .await; 61 51 62 - let cid = image.image.blob().cid(); 63 - cache_blob( 64 - ident_val.to_smolstr(), 65 - cid.to_smolstr(), 66 - image.name.as_ref().map(|n| n.to_smolstr()), 67 - ) 68 - .await 69 - .ok(); 52 + match fetch_result { 53 + Ok(Some(entry)) => { 54 + let (_book_entry_view, entry_record) = (&entry.0, &entry.1); 55 + if let Some(embeds) = &entry_record.embeds { 56 + if let Some(images) = &embeds.images { 57 + let ident_val = ident.clone(); 58 + let images = images.clone(); 59 + for image in &images.images { 60 + use jacquard::smol_str::ToSmolStr; 61 + 62 + let cid = image.image.blob().cid(); 63 + cache_blob( 64 + ident_val.to_smolstr(), 65 + cid.to_smolstr(), 66 + image.name.as_ref().map(|n| n.to_smolstr()), 67 + ) 68 + .await 69 + .ok(); 70 + } 70 71 } 71 72 } 73 + Some(( 74 + serde_json::to_value(entry.0.clone()).unwrap(), 75 + serde_json::to_value(entry.1.clone()).unwrap(), 76 + )) 72 77 } 73 - Some(( 74 - serde_json::to_value(entry.0.clone()).unwrap(), 75 - serde_json::to_value(entry.1.clone()).unwrap(), 76 - )) 77 - } else { 78 - None 78 + Ok(None) => None, 79 + Err(e) => { 80 + tracing::error!( 81 + "[use_entry_data] fetch error for {}/{}/{}: {:?}", 82 + ident(), 83 + book_title(), 84 + title(), 85 + e 86 + ); 87 + None 88 + } 79 89 } 80 90 } 81 91 })); ··· 86 96 87 97 let book_entry = from_json_value::<BookEntryView>(ev.clone()).unwrap(); 88 98 let entry = from_json_value::<Entry>(e.clone()).unwrap(); 89 - 90 99 Some((book_entry, entry)) 91 100 } else { 92 101 None
+6 -10
crates/weaver-app/src/fetch.rs
··· 66 66 } 67 67 68 68 impl HttpClient for Client { 69 - type Error = reqwest::Error; 69 + type Error = IdentityError; 70 70 71 71 #[cfg(not(target_arch = "wasm32"))] 72 72 fn send_http( ··· 77 77 self.oauth_client.client.send_http(request) 78 78 } 79 79 80 - #[doc = " Send an HTTP request and return the response."] 81 80 #[cfg(target_arch = "wasm32")] 82 81 fn send_http( 83 82 &self, ··· 388 387 let stored = Arc::new((notebook, entries)); 389 388 Ok(Some(stored)) 390 389 } else { 391 - Ok(None) 390 + Err(dioxus::CapturedError::from_display("Notebook not found")) 392 391 } 393 392 } 394 393 ··· 409 408 let stored = Arc::new(entry); 410 409 Ok(Some(stored)) 411 410 } else { 412 - Ok(None) 411 + Err(dioxus::CapturedError::from_display("Entry not found")) 413 412 } 414 413 } else { 415 - Ok(None) 414 + Err(dioxus::CapturedError::from_display("Notebook not found")) 416 415 } 417 416 } 418 417 ··· 542 541 543 542 Ok(Some(book_entries)) 544 543 } else { 545 - Ok(None) 544 + Err(dioxus::CapturedError::from_display("Notebook not found")) 546 545 } 547 546 } 548 547 ··· 863 862 // } 864 863 865 864 impl HttpClient for Fetcher { 866 - #[doc = " Error type returned by the HTTP client"] 867 - type Error = reqwest::Error; 865 + type Error = IdentityError; 868 866 869 - #[doc = " Send an HTTP request and return the response."] 870 867 #[cfg(not(target_arch = "wasm32"))] 871 868 fn send_http( 872 869 &self, ··· 879 876 } 880 877 } 881 878 882 - #[doc = " Send an HTTP request and return the response."] 883 879 #[cfg(target_arch = "wasm32")] 884 880 fn send_http( 885 881 &self,
+15 -36
crates/weaver-app/src/main.rs
··· 132 132 let console_level = if cfg!(debug_assertions) { 133 133 Level::DEBUG 134 134 } else { 135 - Level::INFO 135 + Level::DEBUG 136 136 }; 137 137 138 138 let wasm_layer = tracing_wasm::WASMLayer::new( ··· 226 226 let auth_state = use_signal(|| AuthState::default()); 227 227 #[allow(unused)] 228 228 let auth_state = use_context_provider(|| auth_state); 229 - #[cfg(all( 230 - target_family = "wasm", 231 - target_os = "unknown", 232 - feature = "fullstack-server" 233 - ))] 234 - { 229 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 230 + let restore_result = { 235 231 let fetcher = fetcher.clone(); 236 - use_effect(move || { 232 + use_resource(move || { 237 233 let fetcher = fetcher.clone(); 238 - use_future(move || { 239 - let fetcher = fetcher.clone(); 240 - async move { 241 - if let Err(e) = auth::restore_session(fetcher, auth_state).await { 242 - tracing::debug!("Session restoration failed: {}", e); 243 - } 244 - } 245 - }); 246 - }); 247 - } 248 - 249 - #[cfg(all( 250 - target_family = "wasm", 251 - target_os = "unknown", 252 - not(feature = "fullstack-server") 253 - ))] 254 - { 255 - let fetcher = fetcher.clone(); 256 - use_future(move || { 257 - let fetcher = fetcher.clone(); 258 - async move { 259 - if let Err(e) = auth::restore_session(fetcher, auth_state).await { 260 - tracing::debug!("Session restoration failed: {}", e); 261 - } 262 - } 263 - }); 264 - } 234 + async move { auth::restore_session(fetcher, auth_state).await } 235 + }) 236 + }; 237 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 238 + let restore_result: Option<auth::RestoreResult> = None; 265 239 266 240 #[cfg(all( 267 241 target_family = "wasm", ··· 278 252 }); 279 253 } 280 254 255 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 256 + use_context_provider(|| restore_result); 257 + 281 258 rsx! { 282 259 document::Link { rel: "icon", href: FAVICON } 283 260 document::Link { rel: "stylesheet", href: MAIN_CSS } ··· 286 263 document::Link { rel: "preconnect", href: "https://fonts.gstatic.com" } 287 264 288 265 document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS } 289 - Router::<Route> {} 266 + components::toast::ToastProvider { 267 + Router::<Route> {} 268 + } 290 269 } 291 270 } 292 271
+1 -27
crates/weaver-app/src/views/home.rs
··· 9 9 pub fn Home() -> Element { 10 10 // Fetch notebooks from UFOS with SSR support 11 11 let (notebooks_result, notebooks) = data::use_notebooks_from_ufos(); 12 - let navigator = use_navigator(); 13 - let mut uri_input = use_signal(|| String::new()); 14 12 15 - let handle_uri_submit = move || { 16 - let input_uri = uri_input.read().clone(); 17 - if !input_uri.is_empty() { 18 - if let Ok(parsed) = AtUri::new(&input_uri) { 19 - let link = format!("{}/record/{}", crate::env::WEAVER_APP_DOMAIN, parsed); 20 - navigator.push(link); 21 - } 22 - } 23 - }; 24 13 #[cfg(feature = "fullstack-server")] 25 14 notebooks_result 26 15 .as_ref() ··· 32 21 document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } 33 22 div { 34 23 class: "record-view-container", 35 - div { class: "record-header", 36 - div { class: "uri-input-section", 37 - input { 38 - r#type: "text", 39 - class: "uri-input", 40 - placeholder: "at://did:plc:.../collection/rkey", 41 - value: "{uri_input}", 42 - oninput: move |evt| uri_input.set(evt.value()), 43 - onkeydown: move |evt| { 44 - if evt.key() == Key::Enter { 45 - handle_uri_submit(); 46 - } 47 - }, 48 - } 49 - } 50 - } 24 + 51 25 div { class: "notebooks-list", 52 26 match &*notebooks.read() { 53 27 Some(notebook_list) => rsx! {
+150 -17
crates/weaver-app/src/views/navbar.rs
··· 1 1 use crate::Route; 2 - use crate::auth::AuthState; 2 + use crate::auth::{AuthState, RestoreResult}; 3 3 use crate::components::button::{Button, ButtonVariant}; 4 4 use crate::components::login::LoginModal; 5 5 use crate::data::{use_get_handle, use_load_handle}; 6 6 use crate::fetch::Fetcher; 7 7 use dioxus::prelude::*; 8 + use dioxus_primitives::toast::{use_toast, ToastOptions}; 8 9 use jacquard::types::string::Did; 9 10 10 11 const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); ··· 19 20 let route = use_route::<Route>(); 20 21 tracing::trace!("Route: {:?}", route); 21 22 22 - let mut auth_state = use_context::<Signal<crate::auth::AuthState>>(); 23 + let auth_state = use_context::<Signal<crate::auth::AuthState>>(); 24 + 25 + // Show toast if session expired 26 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 27 + { 28 + let restore_result = use_context::<Resource<RestoreResult>>(); 29 + let toast = use_toast(); 30 + let mut shown = use_signal(|| false); 31 + 32 + if !shown() && restore_result() == Some(RestoreResult::SessionExpired) { 33 + shown.set(true); 34 + toast.warning( 35 + "Session Expired".to_string(), 36 + ToastOptions::new().description("Please sign in again"), 37 + ); 38 + } 39 + } 23 40 let (route_handle_res, route_handle) = use_load_handle(match &route { 24 41 Route::EntryPage { ident, .. } => Some(ident.clone()), 25 42 Route::RepositoryIndex { ident } => Some(ident.clone()), 26 43 Route::NotebookIndex { ident, .. } => Some(ident.clone()), 44 + Route::DraftsList { ident } => Some(ident.clone()), 45 + Route::DraftEdit { ident, .. } => Some(ident.clone()), 46 + Route::NewDraft { ident, .. } => Some(ident.clone()), 47 + Route::StandaloneEntry { ident, .. } => Some(ident.clone()), 48 + Route::StandaloneEntryEdit { ident, .. } => Some(ident.clone()), 49 + Route::NotebookEntryByRkey { ident, .. } => Some(ident.clone()), 50 + Route::NotebookEntryEdit { ident, .. } => Some(ident.clone()), 27 51 _ => None, 28 52 }); 29 53 ··· 51 75 let route_handle = route_handle.read().clone(); 52 76 let handle = route_handle.unwrap_or(ident.clone()); 53 77 rsx! { 54 - span { class: "breadcrumb-separator", " > " } 55 - span { class: "breadcrumb breadcrumb-current", "@{handle}" } 78 + span { class:"breadcrumb-separator"," > "} 79 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 56 80 } 57 81 }, 58 - Route::NotebookIndex { ident, book_title } => { 82 + Route::NotebookIndex{ ident, book_title } => { 59 83 let route_handle = route_handle.read().clone(); 60 84 let handle = route_handle.unwrap_or(ident.clone()); 61 85 rsx! { 62 - span { class: "breadcrumb-separator", " > " } 86 + span { class:"breadcrumb-separator"," > " } 63 87 Link { 64 - to: Route::RepositoryIndex { ident: ident.clone() }, 88 + to: Route::RepositoryIndex { ident: ident.clone() 89 + }, 90 + class: "breadcrumb","@{handle}" 91 + } 92 + span{ class: "breadcrumb-separator"," > "} 93 + span{ class: "breadcrumb breadcrumb-current","{book_title}"} 94 + } 95 + }, 96 + Route::EntryPage { ident, book_title, .. } => { 97 + let route_handle=route_handle.read().clone(); 98 + let handle=route_handle.unwrap_or(ident.clone()); 99 + rsx! { 100 + span { class:"breadcrumb-separator"," > "} 101 + Link { 102 + to: Route::RepositoryIndex { 103 + ident:ident.clone() 104 + }, 105 + class:"breadcrumb","@{handle}" 106 + } 107 + span { class:"breadcrumb-separator"," > "} 108 + Link { 109 + to: Route::NotebookIndex { 110 + ident: ident.clone(), 111 + book_title: book_title.clone() 112 + }, 65 113 class: "breadcrumb", 66 - "@{handle}" 114 + "{book_title}" 115 + } 116 + } 117 + }, 118 + Route::DraftsList { ident } => { 119 + let route_handle = route_handle.read().clone(); 120 + let handle = route_handle.unwrap_or(ident.clone()); 121 + rsx! { 122 + span { class:"breadcrumb-separator"," > "} 123 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 124 + } 125 + }, 126 + Route::DraftEdit { ident, tid } => { 127 + let route_handle = route_handle.read().clone(); 128 + let handle = route_handle.unwrap_or(ident.clone()); 129 + rsx! { 130 + span { class:"breadcrumb-separator"," > "} 131 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 132 + } 133 + }, 134 + Route::NewDraft { ident, notebook } => { 135 + let route_handle = route_handle.read().clone(); 136 + let handle = route_handle.unwrap_or(ident.clone()); 137 + if let Some(notebook) = notebook { 138 + rsx! { 139 + span { class:"breadcrumb-separator"," > "} 140 + Link { 141 + to: Route::RepositoryIndex { 142 + ident:ident.clone() 143 + }, 144 + class:"breadcrumb","@{handle}" 145 + } 146 + span { class:"breadcrumb-separator"," > "} 147 + Link { 148 + to: Route::NotebookIndex { 149 + ident: ident.clone(), 150 + book_title: notebook.clone() 151 + }, 152 + class: "breadcrumb", 153 + "{notebook}" 154 + } 67 155 } 68 - span { class: "breadcrumb-separator", " > " } 69 - span { class: "breadcrumb breadcrumb-current", "{book_title}" } 156 + } else { 157 + rsx! { 158 + span { class:"breadcrumb-separator"," > "} 159 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 160 + } 161 + } 162 + }, 163 + Route::StandaloneEntry { ident, .. } => { 164 + let route_handle = route_handle.read().clone(); 165 + let handle = route_handle.unwrap_or(ident.clone()); 166 + rsx! { 167 + span { class:"breadcrumb-separator"," > "} 168 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 70 169 } 71 170 }, 72 - Route::EntryPage { ident, book_title, .. } => { 171 + Route::StandaloneEntryEdit { ident, .. } => { 73 172 let route_handle = route_handle.read().clone(); 74 173 let handle = route_handle.unwrap_or(ident.clone()); 75 174 rsx! { 76 - span { class: "breadcrumb-separator", " > " } 175 + span { class:"breadcrumb-separator"," > "} 176 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 177 + } 178 + }, 179 + Route::NotebookEntryByRkey { ident, book_title, .. } => { 180 + let route_handle=route_handle.read().clone(); 181 + let handle=route_handle.unwrap_or(ident.clone()); 182 + rsx! { 183 + span { class:"breadcrumb-separator"," > "} 184 + Link { 185 + to: Route::RepositoryIndex { 186 + ident:ident.clone() 187 + }, 188 + class:"breadcrumb","@{handle}" 189 + } 190 + span { class:"breadcrumb-separator"," > "} 77 191 Link { 78 - to: Route::RepositoryIndex { ident: ident.clone() }, 192 + to: Route::NotebookIndex { 193 + ident: ident.clone(), 194 + book_title: book_title.clone() 195 + }, 79 196 class: "breadcrumb", 80 - "@{handle}" 197 + "{book_title}" 198 + } 199 + } 200 + }, 201 + Route::NotebookEntryEdit { ident, book_title, .. } => { 202 + let route_handle=route_handle.read().clone(); 203 + let handle=route_handle.unwrap_or(ident.clone()); 204 + rsx! { 205 + span { class:"breadcrumb-separator"," > "} 206 + Link { 207 + to: Route::RepositoryIndex { 208 + ident:ident.clone() 209 + }, 210 + class:"breadcrumb","@{handle}" 81 211 } 82 - span { class: "breadcrumb-separator", " > " } 212 + span { class:"breadcrumb-separator"," > "} 83 213 Link { 84 - to: Route::NotebookIndex { ident: ident.clone(), book_title: book_title.clone() }, 214 + to: Route::NotebookIndex { 215 + ident: ident.clone(), 216 + book_title: book_title.clone() 217 + }, 85 218 class: "breadcrumb", 86 219 "{book_title}" 87 220 } 88 221 } 89 222 }, 90 - _ => rsx! {} 223 + _ => rsx! {}, 91 224 } 92 225 } 93 226 if auth_state.read().is_authenticated() {
+37 -4
crates/weaver-common/src/agent.rs
··· 30 30 31 31 const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; 32 32 33 + /// Strip trailing punctuation that URL parsers commonly eat 34 + /// (period, comma, semicolon, colon, exclamation, question mark) 35 + fn strip_trailing_punctuation(s: &str) -> &str { 36 + s.trim_end_matches(['.', ',', ';', ':', '!', '?']) 37 + } 38 + 39 + /// Check if a search term matches a value, with fallback to stripped punctuation 40 + fn title_matches(value: &str, search: &str) -> bool { 41 + // Exact match first 42 + if value == search { 43 + return true; 44 + } 45 + // Try with trailing punctuation stripped from search term 46 + let stripped_search = strip_trailing_punctuation(search); 47 + if stripped_search != search && value == stripped_search { 48 + return true; 49 + } 50 + // Try with trailing punctuation stripped from value (for titles ending in punctuation) 51 + let stripped_value = strip_trailing_punctuation(value); 52 + if stripped_value != value && stripped_value == search { 53 + return true; 54 + } 55 + false 56 + } 57 + 33 58 /// Extension trait providing weaver-specific multi-step operations on Agent 34 59 /// 35 60 /// This trait extends jacquard's Agent with notebook-specific workflows that ··· 347 372 348 373 let title = notebook.value.title.clone(); 349 374 let tags = notebook.value.tags.clone(); 375 + let path = notebook.value.path.clone(); 350 376 351 377 let mut authors = Vec::new(); 352 378 use weaver_api::app_bsky::actor::{ ··· 383 409 .uri(notebook.uri) 384 410 .indexed_at(jacquard::types::string::Datetime::now()) 385 411 .maybe_title(title) 412 + .maybe_path(path) 386 413 .maybe_tags(tags) 387 414 .authors(authors) 388 415 .record(to_data(&notebook.value).map_err(|_| { ··· 411 438 let entry = self.fetch_record(&entry_uri).await?; 412 439 413 440 let title = entry.value.title.clone(); 441 + let path = entry.value.path.clone(); 414 442 let tags = entry.value.tags.clone(); 415 443 416 444 Ok(EntryView::new() ··· 424 452 })?) 425 453 .maybe_tags(tags) 426 454 .title(title) 455 + .path(path) 427 456 .authors(notebook.authors.clone()) 428 457 .build()) 429 458 } ··· 450 479 .await 451 480 .map_err(|e| AgentError::from(e))?; 452 481 if let Ok(entry) = resp.parse() { 453 - if entry.value.path == title || entry.value.title == title { 482 + let path_matches = title_matches(entry.value.path.as_ref(), title); 483 + let title_field_matches = title_matches(entry.value.title.as_ref(), title); 484 + if path_matches || title_field_matches { 454 485 // Build BookEntryView with prev/next 455 486 let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; 456 487 ··· 549 580 )) 550 581 })?; 551 582 552 - // Match on path first, then title 583 + // Match on path first, then title (with trailing punctuation tolerance) 553 584 let matched_title = if let Some(ref path) = notebook.path 554 - && path.as_ref() == title 585 + && title_matches(path.as_ref(), title) 555 586 { 556 587 Some(path.clone()) 557 588 } else if let Some(ref book_title) = notebook.title 558 - && book_title.as_ref() == title 589 + && title_matches(book_title.as_ref(), title) 559 590 { 560 591 Some(book_title.clone()) 561 592 } else { ··· 564 595 565 596 if let Some(matched) = matched_title { 566 597 let tags = notebook.tags.clone(); 598 + let path = notebook.path.clone(); 567 599 568 600 let mut authors = Vec::new(); 569 601 for (index, author) in notebook.authors.iter().enumerate() { ··· 591 623 .uri(record.uri) 592 624 .indexed_at(jacquard::types::string::Datetime::now()) 593 625 .title(matched) 626 + .maybe_path(path) 594 627 .maybe_tags(tags) 595 628 .authors(authors) 596 629 .record(record.value.clone())
+2
lexicons/notebook/defs.json
··· 8 8 9 9 "properties": { 10 10 "title": { "type": "ref", "ref": "#title" }, 11 + "path": { "type": "ref", "ref": "#path" }, 11 12 "tags": { "type": "ref", "ref": "#tags" }, 12 13 "uri": { "type": "string", "format": "at-uri" }, 13 14 "cid": { "type": "string", "format": "cid" }, ··· 25 26 26 27 "properties": { 27 28 "title": { "type": "ref", "ref": "#title" }, 29 + "path": { "type": "ref", "ref": "#path" }, 28 30 "tags": { "type": "ref", "ref": "#tags" }, 29 31 "uri": { "type": "string", "format": "at-uri" }, 30 32 "cid": { "type": "string", "format": "cid" },