static pages for about, etc.

Orual 1fff2386 6eafc129

+664 -211
+1
.gitignore
··· 16 16 17 17 **/.claude/settings.local.json 18 18 .workspaces/ 19 + **/**.wasm 19 20 **/.obsidian 20 21 **/.trash 21 22 **/bug_notes.md
+38
crates/weaver-app/assets/about.md
··· 1 + # About Weaver 2 + 3 + Weaver is a decentralized platform for people to write, collaborate, and share their writing with others. It is built on the AT Protocol that powers the Bluesky social network, allowing you to truly own what you make without having to run your own website. 4 + 5 + Weaver currently works at the level of "entries" which can be collated into "notebooks". Additional groupings are planned (pages that group short entries, chapters, etc.), but those are the two essential ones. It uses Markdown as its format of choice, rendered beautifully. 6 + 7 + ## Current Features 8 + 9 + - Extended Markdown with wikilinks, embedded content, and math support. 10 + - Obsidian-style hybrid editor that is easy to use 11 + - Real-time or asynchronous collaboration between users on notebook entries 12 + - Themeable notebooks (not directly accessible in the UI yet) 13 + - User profiles (also not directly accessible in the UI yet, interoperability with Bluesky and Tangled profiles) 14 + 15 + ## Planned Features 16 + 17 + - Comprehensive suite of social and discovery features 18 + - User tagging and lists 19 + - Subscriptions and notifications (user, notebook, or tag-level) 20 + - Private drafts and publishing (off-protocol until atproto officially supports private on-protocol data) 21 + - Technically the former already exists, but affordances are bad 22 + - Integration of payment platforms 23 + 24 + # A personal note from the creator 25 + 26 + I'm a writer and a musician as well as an engineer. 27 + 28 + If you want to read more about the motivation behind Weaver, read [this](https://alpha.weaver.sh/did:plc:yfvwmnlztr4dwkb7hwz55r2g/weaver/weaver_-_long-form_writing), my first public writing about and on the platform. Weaver exists in part because I was unsatisfied with the other platforms available for me to write on. I'm betting on not being the only one looking for something better, and so I decided to take on the challenge of doing just that, for myself and because I felt like nobody else was well-positioned to, as writing ability and engineering ability are not strongly correlated skillsets. 29 + 30 + An ongoing series of development logs lives in [this notebook](https://alpha.weaver.sh/did:plc:yfvwmnlztr4dwkb7hwz55r2g/weaver/). All of the writing I've done here is available at my [profile page](https://alpha.weaver.sh/did:plc:yfvwmnlztr4dwkb7hwz55r2g/). 31 + 32 + Weaver is still very much a work in progress, and I consider it in an **alpha** state of completion. There is much that is incomplete, but it is usable as a writing platfom, though at times buggy. I would love for you to try it out, and if you run into problems, particularly with the editor, would welcome your feedback. 33 + 34 + ### Contribution 35 + 36 + Weaver is open source software, released under the Mozilla Public License, version 2.0. You can find the source code and contribute (or report bugs) at [tangled.org](https://tangled.org/nonbinary.computer/weaver). 37 + 38 + I am currently working on this in my spare time as something of a labour of love, and as such would appreciate donations via [Github Sponsorship](https://github.com/sponsors/orual).
+27
crates/weaver-app/assets/privacy.md
··· 1 + # Privacy Policy 2 + 3 + *Last updated: December 2025* 4 + 5 + This privacy policy is a placeholder. A proper privacy policy will be added before Weaver moves into beta. 6 + 7 + For an explanation of the state of things and the philosophy, please read [this devlog](https://alpha.weaver.sh/did:plc:yfvwmnlztr4dwkb7hwz55r2g/weaver/drafts_privacy). 8 + 9 + ## Data Collection 10 + 11 + Weaver itself does not collect personal data. However: 12 + 13 + - **AT Protocol**: When you authenticate and publish content or sync drafts, that data is stored on your AT Protocol Personal Data Server (PDS) according to the PDS operator's privacy policy. AT Protocol data is almost entirely public. All AT Protocol data Weaver creates and manages on your behalf is readable by anyone with the right tools. This is a protocol limitation. Data can be obfuscated or requested to be hidden, but aside from a small subset of Bluesky-specific data it cannot be hidden from public view. 14 + - **Bluesky**: If you use a Bluesky-operated PDS, Bluesky's privacy policy applies to that data. 15 + - **Iroh**: Real-time collaboration currently traverses Iroh's public relays due to browser limitations. The data is encrypted end-to-end with an ephemeral session key and cannot be read by them. Weaver will host its own Iroh relay(s) in the future for production use, with similar guarantees. 16 + 17 + ## Cookies 18 + 19 + Weaver uses local storage to maintain your authentication session and unsynced draft state. No tracking cookies are used. 20 + 21 + ## Analytics 22 + 23 + Weaver currently uses a Cloudflare tunnel to proxy the app server out to the public web and has Cloudflare's analytics enabled, which collects basic location and performance metrics. 24 + 25 + ## Contact 26 + 27 + For privacy concerns, please open an issue on the [project repository](https://tangled.org/nonbinary.computer/weaver/issues) or email contact(at)weaver.sh.
+93
crates/weaver-app/assets/styling/footer.css
··· 1 + /* Site footer - two tiers: minimal (always) and full (shell pages only) */ 2 + 3 + .site-footer { 4 + display: flex; 5 + justify-content: center; 6 + padding: 1.5rem 1rem; 7 + margin-top: auto; 8 + border-top: 1px solid var(--color-border); 9 + } 10 + 11 + .footer-content { 12 + display: flex; 13 + flex-direction: column; 14 + align-items: center; 15 + gap: 0.75rem; 16 + max-width: 1200px; 17 + width: 100%; 18 + } 19 + 20 + .footer-links { 21 + display: flex; 22 + flex-wrap: wrap; 23 + justify-content: center; 24 + align-items: center; 25 + gap: 1rem; 26 + } 27 + 28 + .footer-link { 29 + display: flex; 30 + align-items: center; 31 + gap: 0.35rem; 32 + color: var(--color-muted); 33 + text-decoration: none; 34 + font-size: 0.8125rem; 35 + font-family: var(--font-ui); 36 + transition: color 0.15s ease; 37 + } 38 + 39 + .footer-link:hover { 40 + color: var(--color-text); 41 + } 42 + 43 + .footer-link svg { 44 + width: 14px; 45 + height: 14px; 46 + } 47 + 48 + .footer-separator { 49 + color: var(--color-muted); 50 + user-select: none; 51 + font-size: 0.75rem; 52 + } 53 + 54 + /* Minimal footer for user content pages */ 55 + .site-footer-minimal { 56 + display: flex; 57 + justify-content: center; 58 + padding: 1rem; 59 + margin-top: auto; 60 + border-top: 1px solid var(--color-border); 61 + } 62 + 63 + .site-footer-minimal .footer-links { 64 + gap: 0.75rem; 65 + } 66 + 67 + .site-footer-minimal .footer-link { 68 + font-size: 0.75rem; 69 + color: var(--color-subtle); 70 + } 71 + 72 + .site-footer-minimal .footer-link:hover { 73 + color: var(--color-muted); 74 + } 75 + 76 + .site-footer-minimal .footer-separator { 77 + font-size: 0.625rem; 78 + } 79 + 80 + /* Mobile: stack vertically on full footer */ 81 + @media (max-width: 480px) { 82 + .site-footer .footer-content { 83 + gap: 0.5rem; 84 + } 85 + 86 + .site-footer .footer-links { 87 + gap: 0.5rem 0.75rem; 88 + } 89 + 90 + .site-footer .footer-separator { 91 + display: none; 92 + } 93 + }
+10
crates/weaver-app/assets/styling/main.css
··· 6 6 font-family: var(--font-ui); 7 7 } 8 8 9 + .app-shell { 10 + display: flex; 11 + flex-direction: column; 12 + min-height: 100vh; 13 + } 14 + 15 + .app-main { 16 + flex: 1; 17 + } 18 + 9 19 a { 10 20 color: var(--color-link); 11 21 text-decoration: none;
+19
crates/weaver-app/assets/terms.md
··· 1 + # Terms of Service 2 + 3 + *Last updated: December 2025* 4 + 5 + These terms are a preliminary placeholder and will be updated before public beta release. 6 + 7 + ## Use at Your Own Risk 8 + 9 + Weaver is currently in alpha. Features will change, there are bugs, and data loss is possible. 10 + 11 + ## Content 12 + 13 + You are responsible for the content you publish through Weaver. Content is stored on AT Protocol Personal Data Servers according to those servers' terms of service, though it is temporarily cached on Weaver's server. A persistent index and cache will be added in the near future. This will respect deletion requests as much as is reasonable, and in complicance with regulations and laws local to the user (e.g. the EU "right to be forgotten"). Weaver as is can be used to view any compatible public atproto data if it exists, in its raw form. It will not decode or display in a human-readable format the contents of drafts you do not have edit rights for. 14 + 15 + Weaver is developed and hosted in Canada and is subject to its laws regarding copyright, obscenity, etc. We are fans of freedom of expression, but we can and will refuse to serve content that puts us at substantial legal risk. In the interests of clarity, Canada has some specific laws around drawings or animations depicting minors in a sexually explicit way that we have no interest in testing. 16 + 17 + ## No Warranty 18 + 19 + This software is provided "as is" without warranty of any kind.
+7 -7
crates/weaver-app/public/editor_worker.js
··· 442 442 const ret = arg0.versions; 443 443 return ret; 444 444 }; 445 + imports.wbg.__wbindgen_cast_1511eb630aa228f5 = function(arg0, arg1) { 446 + // Cast intrinsic for `Closure(Closure { dtor_idx: 940, function: Function { arguments: [Externref], shim_idx: 941, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 447 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 448 + return ret; 449 + }; 445 450 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 446 451 // Cast intrinsic for `Ref(String) -> Externref`. 447 452 const ret = getStringFromWasm0(arg0, arg1); 448 453 return ret; 449 454 }; 450 - imports.wbg.__wbindgen_cast_3fda284bdcf7704e = function(arg0, arg1) { 451 - // Cast intrinsic for `Closure(Closure { dtor_idx: 941, function: Function { arguments: [Externref], shim_idx: 942, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 452 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 453 - return ret; 454 - }; 455 455 imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { 456 456 // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. 457 457 const ret = getArrayU8FromWasm0(arg0, arg1); 458 458 return ret; 459 459 }; 460 - imports.wbg.__wbindgen_cast_e969152242ffebd9 = function(arg0, arg1) { 461 - // Cast intrinsic for `Closure(Closure { dtor_idx: 104, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 105, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 460 + imports.wbg.__wbindgen_cast_ce6245619dc560a7 = function(arg0, arg1) { 461 + // Cast intrinsic for `Closure(Closure { dtor_idx: 102, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 103, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 462 462 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 463 463 return ret; 464 464 };
+15 -15
crates/weaver-app/public/embed_worker.js
··· 236 236 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 237 237 } 238 238 239 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 240 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 241 - } 242 - 243 239 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 244 240 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 241 + } 242 + 243 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 244 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 245 245 } 246 246 247 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { ··· 612 612 const ret = getStringFromWasm0(arg0, arg1); 613 613 return ret; 614 614 }; 615 - imports.wbg.__wbindgen_cast_82f5386d361eee3f = function(arg0, arg1) { 616 - // Cast intrinsic for `Closure(Closure { dtor_idx: 441, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 442, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 617 - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 618 - return ret; 619 - }; 620 - imports.wbg.__wbindgen_cast_bc7590ff5cbfa2f9 = function(arg0, arg1) { 621 - // Cast intrinsic for `Closure(Closure { dtor_idx: 2214, function: Function { arguments: [Externref], shim_idx: 2215, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 615 + imports.wbg.__wbindgen_cast_36dddc5933837ecc = function(arg0, arg1) { 616 + // Cast intrinsic for `Closure(Closure { dtor_idx: 2216, function: Function { arguments: [Externref], shim_idx: 2217, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 622 617 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 623 618 return ret; 624 619 }; 625 - imports.wbg.__wbindgen_cast_c2fcf804dba34eff = function(arg0, arg1) { 626 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1186, function: Function { arguments: [], shim_idx: 1187, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 620 + imports.wbg.__wbindgen_cast_3eeb44a0158730cb = function(arg0, arg1) { 621 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1188, function: Function { arguments: [], shim_idx: 1189, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 627 622 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 628 623 return ret; 629 624 }; 630 - imports.wbg.__wbindgen_cast_fbdf1c7b4b56bb43 = function(arg0, arg1) { 631 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1446, function: Function { arguments: [], shim_idx: 1447, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 625 + imports.wbg.__wbindgen_cast_b5fa8180acb99032 = function(arg0, arg1) { 626 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1448, function: Function { arguments: [], shim_idx: 1449, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 632 627 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 628 + return ret; 629 + }; 630 + imports.wbg.__wbindgen_cast_d976f3c9b97a6409 = function(arg0, arg1) { 631 + // Cast intrinsic for `Closure(Closure { dtor_idx: 272, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 273, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 632 + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 633 633 return ret; 634 634 }; 635 635 imports.wbg.__wbindgen_init_externref_table = function() {
+4 -2
crates/weaver-app/src/env.rs
··· 15 15 #[allow(unused)] 16 16 pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; 17 17 #[allow(unused)] 18 - pub const WEAVER_TOS_URI: &'static str = ""; 18 + pub const WEAVER_TOS_URI: &'static str = "https://alpha.weaver.sh/tos"; 19 19 #[allow(unused)] 20 - pub const WEAVER_PRIVACY_POLICY_URI: &'static str = ""; 20 + pub const WEAVER_PRIVACY_POLICY_URI: &'static str = "https://alpha.weaver.sh/privacy"; 21 + #[allow(unused)] 22 + pub const WEAVER_OWNER_DID: &'static str = "did:plc:yfvwmnlztr4dwkb7hwz55r2g";
+9 -3
crates/weaver-app/src/lib.rs
··· 35 35 use config::{Config, OAuthConfig}; 36 36 #[allow(unused)] 37 37 use views::{ 38 - Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, Navbar, NewDraft, Notebook, 39 - NotebookEntryByRkey, NotebookEntryEdit, NotebookIndex, NotebookPage, RecordIndex, RecordPage, 40 - StandaloneEntry, StandaloneEntryEdit, 38 + AboutPage, Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, Navbar, NewDraft, 39 + Notebook, NotebookEntryByRkey, NotebookEntryEdit, NotebookIndex, NotebookPage, PrivacyPage, 40 + RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit, TermsPage, 41 41 }; 42 42 43 43 #[derive(Debug, Clone, Routable, PartialEq)] ··· 48 48 Home {}, 49 49 #[route("/editor?:entry")] 50 50 Editor { entry: Option<String> }, 51 + #[route("/about")] 52 + AboutPage {}, 53 + #[route("/tos")] 54 + TermsPage {}, 55 + #[route("/privacy")] 56 + PrivacyPage {}, 51 57 #[layout(ErrorLayout)] 52 58 #[nest("/record")] 53 59 #[layout(RecordIndex)]
+171
crates/weaver-app/src/views/footer.rs
··· 1 + use crate::Route; 2 + use crate::components::{BskyIcon, TangledIcon}; 3 + use dioxus::prelude::*; 4 + use jacquard::types::string::AtIdentifier; 5 + 6 + const FOOTER_CSS: Asset = asset!("/assets/styling/footer.css"); 7 + 8 + const TANGLED_REPO_URL: &str = "https://tangled.org/nonbinary.computer/weaver"; 9 + const TANGLED_ISSUES_URL: &str = "https://tangled.org/nonbinary.computer/weaver/issues"; 10 + const BSKY_URL: &str = "https://bsky.app/profile/nonbinary.computer"; 11 + const GITHUB_SPONSORS_URL: &str = "https://github.com/sponsors/orual"; 12 + 13 + /// Determines if the current route should show the full footer or just the minimal version. 14 + /// Full footer shows on shell pages (Home, Editor) and on owner's content pages. 15 + fn should_show_full_footer(route: &Route) -> bool { 16 + match route { 17 + // Shell pages: always show full footer 18 + Route::Home {} 19 + | Route::Editor { .. } 20 + | Route::AboutPage {} 21 + | Route::TermsPage {} 22 + | Route::PrivacyPage {} => true, 23 + 24 + // Callback is transient, minimal is fine 25 + Route::Callback { .. } => false, 26 + 27 + // Record viewer shows arbitrary user content 28 + Route::RecordPage { .. } => false, 29 + 30 + // User content pages: check if owner 31 + Route::RepositoryIndex { ident } 32 + | Route::DraftsList { ident } 33 + | Route::DraftEdit { ident, .. } 34 + | Route::NewDraft { ident, .. } 35 + | Route::InvitesPage { ident } 36 + | Route::StandaloneEntry { ident, .. } 37 + | Route::StandaloneEntryEdit { ident, .. } 38 + | Route::NotebookIndex { ident, .. } 39 + | Route::EntryPage { ident, .. } 40 + | Route::NotebookEntryByRkey { ident, .. } 41 + | Route::NotebookEntryEdit { ident, .. } => is_owner_ident(ident), 42 + } 43 + } 44 + 45 + /// Check if the given identifier matches the site owner DID. 46 + fn is_owner_ident(ident: &AtIdentifier<'static>) -> bool { 47 + let owner_did = crate::env::WEAVER_OWNER_DID; 48 + if owner_did.is_empty() { 49 + return false; 50 + } 51 + 52 + match ident { 53 + AtIdentifier::Did(did) => did.as_ref() == owner_did, 54 + // Could resolve handle to DID, but keeping it simple for now 55 + AtIdentifier::Handle(_) => false, 56 + } 57 + } 58 + 59 + #[component] 60 + pub fn Footer() -> Element { 61 + let route = use_route::<Route>(); 62 + let show_full = should_show_full_footer(&route); 63 + 64 + rsx! { 65 + document::Link { rel: "stylesheet", href: FOOTER_CSS } 66 + 67 + if show_full { 68 + footer { class: "site-footer", 69 + div { class: "footer-content", 70 + div { class: "footer-links", 71 + a { 72 + href: "{crate::env::WEAVER_APP_HOST}/about", 73 + class: "footer-link", 74 + "About" 75 + } 76 + 77 + span { class: "footer-separator", "|" } 78 + 79 + a { 80 + href: crate::env::WEAVER_TOS_URI, 81 + class: "footer-link", 82 + "Terms" 83 + } 84 + 85 + span { class: "footer-separator", "|" } 86 + 87 + a { 88 + href: crate::env::WEAVER_PRIVACY_POLICY_URI, 89 + class: "footer-link", 90 + "Privacy" 91 + } 92 + 93 + span { class: "footer-separator", "|" } 94 + 95 + a { 96 + href: TANGLED_REPO_URL, 97 + class: "footer-link", 98 + target: "_blank", 99 + rel: "noopener", 100 + TangledIcon { 101 + height: Some(14), 102 + width: Some(14), 103 + } 104 + "Source" 105 + } 106 + 107 + span { class: "footer-separator", "|" } 108 + 109 + a { 110 + href: TANGLED_ISSUES_URL, 111 + class: "footer-link", 112 + target: "_blank", 113 + rel: "noopener", 114 + "Report Bug" 115 + } 116 + 117 + span { class: "footer-separator", "|" } 118 + 119 + a { 120 + href: BSKY_URL, 121 + class: "footer-link", 122 + target: "_blank", 123 + rel: "noopener", 124 + BskyIcon { 125 + height: Some(14), 126 + width: Some(14), 127 + } 128 + "Bluesky" 129 + } 130 + 131 + span { class: "footer-separator", "|" } 132 + 133 + a { 134 + href: GITHUB_SPONSORS_URL, 135 + class: "footer-link", 136 + target: "_blank", 137 + rel: "noopener", 138 + "Sponsor" 139 + } 140 + } 141 + } 142 + } 143 + } else { 144 + footer { class: "site-footer-minimal", 145 + div { class: "footer-links", 146 + a { 147 + href: TANGLED_REPO_URL, 148 + class: "footer-link", 149 + target: "_blank", 150 + rel: "noopener", 151 + TangledIcon { 152 + height: Some(12), 153 + width: Some(12), 154 + } 155 + "Source" 156 + } 157 + 158 + span { class: "footer-separator", "|" } 159 + 160 + a { 161 + href: TANGLED_ISSUES_URL, 162 + class: "footer-link", 163 + target: "_blank", 164 + rel: "noopener", 165 + "Report Bug" 166 + } 167 + } 168 + } 169 + } 170 + } 171 + }
+6
crates/weaver-app/src/views/mod.rs
··· 37 37 38 38 mod invites; 39 39 pub use invites::InvitesPage; 40 + 41 + mod footer; 42 + pub use footer::Footer; 43 + 44 + mod static_page; 45 + pub use static_page::{AboutPage, PrivacyPage, TermsPage};
+192 -184
crates/weaver-app/src/views/navbar.rs
··· 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 + use crate::views::Footer; 7 8 use dioxus::prelude::*; 8 9 use dioxus_primitives::toast::{ToastOptions, use_toast}; 9 10 use jacquard::types::ident::AtIdentifier; ··· 67 68 document::Link { rel: "stylesheet", href: CARDS_BASE_CSS } 68 69 document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } 69 70 document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } 70 - div { 71 - id: "navbar", 72 - nav { class: "breadcrumbs", 73 - // On home page: show profile link if authenticated, otherwise "Home" 74 - match (&route, &auth_state.read().did) { 75 - (Route::Home {}, Some(did)) => rsx! { 76 - ProfileBreadcrumb { did: did.clone() } 77 - }, 78 - _ => rsx! { 79 - a { 80 - href: "/", 81 - class: "breadcrumb", 82 - "Home" 83 - } 84 - } 85 - } 86 71 87 - // Show repository breadcrumb if we're on a repository page 88 - match &route { 89 - Route::RepositoryIndex { ident } => { 90 - let route_handle = route_handle.read().clone(); 91 - let handle = route_handle.unwrap_or(ident.clone()); 92 - rsx! { 93 - span { class:"breadcrumb-separator"," > "} 94 - span { class:"breadcrumb breadcrumb-current","@{handle}"} 95 - } 96 - }, 97 - Route::NotebookIndex{ ident, book_title } => { 98 - let route_handle = route_handle.read().clone(); 99 - let handle = route_handle.unwrap_or(ident.clone()); 100 - rsx! { 101 - span { class:"breadcrumb-separator"," > " } 102 - Link { 103 - to: Route::RepositoryIndex { ident: ident.clone() 104 - }, 105 - class: "breadcrumb","@{handle}" 106 - } 107 - span{ class: "breadcrumb-separator"," > "} 108 - span{ class: "breadcrumb breadcrumb-current","{book_title}"} 109 - } 110 - }, 111 - Route::EntryPage { ident, book_title, .. } => { 112 - let route_handle=route_handle.read().clone(); 113 - let handle=route_handle.unwrap_or(ident.clone()); 114 - rsx! { 115 - span { class:"breadcrumb-separator"," > "} 116 - Link { 117 - to: Route::RepositoryIndex { 118 - ident:ident.clone() 119 - }, 120 - class:"breadcrumb","@{handle}" 121 - } 122 - span { class:"breadcrumb-separator"," > "} 123 - Link { 124 - to: Route::NotebookIndex { 125 - ident: ident.clone(), 126 - book_title: book_title.clone() 127 - }, 72 + div { class: "app-shell", 73 + div { 74 + id: "navbar", 75 + nav { class: "breadcrumbs", 76 + // On home page: show profile link if authenticated, otherwise "Home" 77 + match (&route, &auth_state.read().did) { 78 + (Route::Home {}, Some(did)) => rsx! { 79 + ProfileBreadcrumb { did: did.clone() } 80 + }, 81 + _ => rsx! { 82 + a { 83 + href: "/", 128 84 class: "breadcrumb", 129 - "{book_title}" 85 + "Home" 130 86 } 131 87 } 132 - }, 133 - Route::DraftsList { ident } => { 134 - let route_handle = route_handle.read().clone(); 135 - let handle = route_handle.unwrap_or(ident.clone()); 136 - rsx! { 137 - span { class:"breadcrumb-separator"," > "} 138 - Link { 139 - to: Route::RepositoryIndex { ident: ident.clone() 140 - }, 141 - class: "breadcrumb","@{handle}" 88 + } 89 + 90 + // Show repository breadcrumb if we're on a repository page 91 + match &route { 92 + Route::RepositoryIndex { ident } => { 93 + let route_handle = route_handle.read().clone(); 94 + let handle = route_handle.unwrap_or(ident.clone()); 95 + rsx! { 96 + span { class:"breadcrumb-separator"," > "} 97 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 142 98 } 143 - } 144 - }, 145 - Route::DraftEdit { ident, .. } => { 146 - let route_handle = route_handle.read().clone(); 147 - let handle = route_handle.unwrap_or(ident.clone()); 148 - rsx! { 149 - span { class:"breadcrumb-separator"," > "} 150 - Link { 151 - to: Route::RepositoryIndex { ident: ident.clone() 152 - }, 153 - class: "breadcrumb","@{handle}" 99 + }, 100 + Route::NotebookIndex{ ident, book_title } => { 101 + let route_handle = route_handle.read().clone(); 102 + let handle = route_handle.unwrap_or(ident.clone()); 103 + rsx! { 104 + span { class:"breadcrumb-separator"," > " } 105 + Link { 106 + to: Route::RepositoryIndex { ident: ident.clone() 107 + }, 108 + class: "breadcrumb","@{handle}" 109 + } 110 + span{ class: "breadcrumb-separator"," > "} 111 + span{ class: "breadcrumb breadcrumb-current","{book_title}"} 154 112 } 155 - } 156 - }, 157 - Route::NewDraft { ident, notebook } => { 158 - let route_handle = route_handle.read().clone(); 159 - let handle = route_handle.unwrap_or(ident.clone()); 160 - if let Some(notebook) = notebook { 113 + }, 114 + Route::EntryPage { ident, book_title, .. } => { 115 + let route_handle=route_handle.read().clone(); 116 + let handle=route_handle.unwrap_or(ident.clone()); 161 117 rsx! { 162 118 span { class:"breadcrumb-separator"," > "} 163 119 Link { ··· 170 126 Link { 171 127 to: Route::NotebookIndex { 172 128 ident: ident.clone(), 173 - book_title: notebook.clone() 129 + book_title: book_title.clone() 174 130 }, 175 131 class: "breadcrumb", 176 - "{notebook}" 132 + "{book_title}" 177 133 } 178 134 } 179 - } else { 135 + }, 136 + Route::DraftsList { ident } => { 137 + let route_handle = route_handle.read().clone(); 138 + let handle = route_handle.unwrap_or(ident.clone()); 180 139 rsx! { 181 140 span { class:"breadcrumb-separator"," > "} 182 - span { class:"breadcrumb breadcrumb-current","@{handle}"} 141 + Link { 142 + to: Route::RepositoryIndex { ident: ident.clone() 143 + }, 144 + class: "breadcrumb","@{handle}" 145 + } 183 146 } 184 - } 185 - }, 186 - Route::StandaloneEntry { ident, .. } => { 187 - let route_handle = route_handle.read().clone(); 188 - let handle = route_handle.unwrap_or(ident.clone()); 189 - rsx! { 190 - span { class:"breadcrumb-separator"," > "} 191 - Link { 192 - to: Route::RepositoryIndex { ident: ident.clone() 193 - }, 194 - class: "breadcrumb","@{handle}" 147 + }, 148 + Route::DraftEdit { ident, .. } => { 149 + let route_handle = route_handle.read().clone(); 150 + let handle = route_handle.unwrap_or(ident.clone()); 151 + rsx! { 152 + span { class:"breadcrumb-separator"," > "} 153 + Link { 154 + to: Route::RepositoryIndex { ident: ident.clone() 155 + }, 156 + class: "breadcrumb","@{handle}" 157 + } 195 158 } 196 - } 197 - }, 198 - Route::StandaloneEntryEdit { ident, .. } => { 199 - let route_handle = route_handle.read().clone(); 200 - let handle = route_handle.unwrap_or(ident.clone()); 201 - rsx! { 202 - span { class:"breadcrumb-separator"," > "} 203 - Link { 204 - to: Route::RepositoryIndex { ident: ident.clone() 205 - }, 206 - class: "breadcrumb","@{handle}" 159 + }, 160 + Route::NewDraft { ident, notebook } => { 161 + let route_handle = route_handle.read().clone(); 162 + let handle = route_handle.unwrap_or(ident.clone()); 163 + if let Some(notebook) = notebook { 164 + rsx! { 165 + span { class:"breadcrumb-separator"," > "} 166 + Link { 167 + to: Route::RepositoryIndex { 168 + ident:ident.clone() 169 + }, 170 + class:"breadcrumb","@{handle}" 171 + } 172 + span { class:"breadcrumb-separator"," > "} 173 + Link { 174 + to: Route::NotebookIndex { 175 + ident: ident.clone(), 176 + book_title: notebook.clone() 177 + }, 178 + class: "breadcrumb", 179 + "{notebook}" 180 + } 181 + } 182 + } else { 183 + rsx! { 184 + span { class:"breadcrumb-separator"," > "} 185 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 186 + } 207 187 } 208 - } 209 - }, 210 - Route::NotebookEntryByRkey { ident, book_title, .. } => { 211 - let route_handle=route_handle.read().clone(); 212 - let handle=route_handle.unwrap_or(ident.clone()); 213 - rsx! { 214 - span { class:"breadcrumb-separator"," > "} 215 - Link { 216 - to: Route::RepositoryIndex { 217 - ident:ident.clone() 218 - }, 219 - class:"breadcrumb","@{handle}" 188 + }, 189 + Route::StandaloneEntry { ident, .. } => { 190 + let route_handle = route_handle.read().clone(); 191 + let handle = route_handle.unwrap_or(ident.clone()); 192 + rsx! { 193 + span { class:"breadcrumb-separator"," > "} 194 + Link { 195 + to: Route::RepositoryIndex { ident: ident.clone() 196 + }, 197 + class: "breadcrumb","@{handle}" 198 + } 220 199 } 221 - span { class:"breadcrumb-separator"," > "} 222 - Link { 223 - to: Route::NotebookIndex { 224 - ident: ident.clone(), 225 - book_title: book_title.clone() 226 - }, 227 - class: "breadcrumb", 228 - "{book_title}" 200 + }, 201 + Route::StandaloneEntryEdit { ident, .. } => { 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 { ident: ident.clone() 208 + }, 209 + class: "breadcrumb","@{handle}" 210 + } 229 211 } 230 - } 231 - }, 232 - Route::NotebookEntryEdit { ident, book_title, .. } => { 233 - let route_handle=route_handle.read().clone(); 234 - let handle=route_handle.unwrap_or(ident.clone()); 235 - rsx! { 236 - span { class:"breadcrumb-separator"," > "} 237 - Link { 238 - to: Route::RepositoryIndex { 239 - ident:ident.clone() 240 - }, 241 - class:"breadcrumb","@{handle}" 212 + }, 213 + Route::NotebookEntryByRkey { ident, book_title, .. } => { 214 + let route_handle=route_handle.read().clone(); 215 + let handle=route_handle.unwrap_or(ident.clone()); 216 + rsx! { 217 + span { class:"breadcrumb-separator"," > "} 218 + Link { 219 + to: Route::RepositoryIndex { 220 + ident:ident.clone() 221 + }, 222 + class:"breadcrumb","@{handle}" 223 + } 224 + span { class:"breadcrumb-separator"," > "} 225 + Link { 226 + to: Route::NotebookIndex { 227 + ident: ident.clone(), 228 + book_title: book_title.clone() 229 + }, 230 + class: "breadcrumb", 231 + "{book_title}" 232 + } 242 233 } 243 - span { class:"breadcrumb-separator"," > "} 244 - Link { 245 - to: Route::NotebookIndex { 246 - ident: ident.clone(), 247 - book_title: book_title.clone() 248 - }, 249 - class: "breadcrumb", 250 - "{book_title}" 234 + }, 235 + Route::NotebookEntryEdit { ident, book_title, .. } => { 236 + let route_handle=route_handle.read().clone(); 237 + let handle=route_handle.unwrap_or(ident.clone()); 238 + rsx! { 239 + span { class:"breadcrumb-separator"," > "} 240 + Link { 241 + to: Route::RepositoryIndex { 242 + ident:ident.clone() 243 + }, 244 + class:"breadcrumb","@{handle}" 245 + } 246 + span { class:"breadcrumb-separator"," > "} 247 + Link { 248 + to: Route::NotebookIndex { 249 + ident: ident.clone(), 250 + book_title: book_title.clone() 251 + }, 252 + class: "breadcrumb", 253 + "{book_title}" 254 + } 251 255 } 252 - } 253 - }, 254 - _ => rsx! {}, 256 + }, 257 + _ => rsx! {}, 258 + } 255 259 } 256 - } 257 260 258 - // Tool links (show on home page) 259 - if matches!(route, Route::Home {}) { 260 - nav { class: "nav-tools", 261 - Link { 262 - to: Route::RecordPage { uri: vec![] }, 263 - class: "nav-tool-link", 264 - "Record Viewer" 265 - } 266 - Link { 267 - to: Route::Editor { entry: None }, 268 - class: "nav-tool-link", 269 - "Editor" 261 + // Tool links (show on home page) 262 + if matches!(route, Route::Home {}) { 263 + nav { class: "nav-tools", 264 + Link { 265 + to: Route::RecordPage { uri: vec![] }, 266 + class: "nav-tool-link", 267 + "Record Viewer" 268 + } 269 + Link { 270 + to: Route::Editor { entry: None }, 271 + class: "nav-tool-link", 272 + "Editor" 273 + } 270 274 } 271 275 } 272 - } 273 276 274 - if auth_state.read().is_authenticated() { 275 - if let Some(did) = &auth_state.read().did { 276 - AuthButton { did: did.clone() } 277 - } 278 - } else { 279 - div { 280 - class: "auth-button", 281 - Button { 282 - variant: ButtonVariant::Ghost, 283 - onclick: move |_| show_login_modal.set(true), 284 - span { class: "auth-handle", "Sign In" } 277 + if auth_state.read().is_authenticated() { 278 + if let Some(did) = &auth_state.read().did { 279 + AuthButton { did: did.clone() } 285 280 } 281 + } else { 282 + div { 283 + class: "auth-button", 284 + Button { 285 + variant: ButtonVariant::Ghost, 286 + onclick: move |_| show_login_modal.set(true), 287 + span { class: "auth-handle", "Sign In" } 288 + } 286 289 290 + } 291 + LoginModal { 292 + open: show_login_modal 293 + } 287 294 } 288 - LoginModal { 289 - open: show_login_modal 290 - } 295 + } 296 + 297 + main { class: "app-main", 298 + Outlet::<Route> {} 291 299 } 300 + 301 + Footer {} 292 302 } 293 - 294 - Outlet::<Route> {} 295 303 } 296 304 } 297 305
+72
crates/weaver-app/src/views/static_page.rs
··· 1 + use crate::components::css::DefaultNotebookCss; 2 + use crate::components::ENTRY_CSS; 3 + use dioxus::prelude::*; 4 + use weaver_renderer::atproto::ClientWriter; 5 + 6 + const ABOUT_MD: &str = include_str!("../../assets/about.md"); 7 + const TERMS_MD: &str = include_str!("../../assets/terms.md"); 8 + const PRIVACY_MD: &str = include_str!("../../assets/privacy.md"); 9 + 10 + fn render_markdown(content: &str) -> String { 11 + let parser = markdown_weaver::Parser::new_ext(content, weaver_renderer::default_md_options()); 12 + let mut html = String::new(); 13 + let _ = ClientWriter::<_, _, ()>::new(parser, &mut html).run(); 14 + html 15 + } 16 + 17 + #[derive(Clone, Copy, PartialEq)] 18 + pub enum StaticPageKind { 19 + About, 20 + Terms, 21 + Privacy, 22 + } 23 + 24 + impl StaticPageKind { 25 + fn content(&self) -> &'static str { 26 + match self { 27 + StaticPageKind::About => ABOUT_MD, 28 + StaticPageKind::Terms => TERMS_MD, 29 + StaticPageKind::Privacy => PRIVACY_MD, 30 + } 31 + } 32 + 33 + fn title(&self) -> &'static str { 34 + match self { 35 + StaticPageKind::About => "About", 36 + StaticPageKind::Terms => "Terms of Service", 37 + StaticPageKind::Privacy => "Privacy Policy", 38 + } 39 + } 40 + } 41 + 42 + #[component] 43 + pub fn StaticPage(kind: StaticPageKind) -> Element { 44 + let html = render_markdown(kind.content()); 45 + 46 + rsx! { 47 + DefaultNotebookCss {} 48 + document::Link { rel: "stylesheet", href: ENTRY_CSS } 49 + document::Title { "{kind.title()} - Weaver" } 50 + 51 + div { class: "static-page", 52 + article { class: "entry notebook-content", 53 + dangerous_inner_html: "{html}" 54 + } 55 + } 56 + } 57 + } 58 + 59 + #[component] 60 + pub fn AboutPage() -> Element { 61 + rsx! { StaticPage { kind: StaticPageKind::About } } 62 + } 63 + 64 + #[component] 65 + pub fn TermsPage() -> Element { 66 + rsx! { StaticPage { kind: StaticPageKind::Terms } } 67 + } 68 + 69 + #[component] 70 + pub fn PrivacyPage() -> Element { 71 + rsx! { StaticPage { kind: StaticPageKind::Privacy } } 72 + }