static pages for about, etc.

Orual 1fff2386 6eafc129

+664 -211
+1
.gitignore
··· 16 17 **/.claude/settings.local.json 18 .workspaces/ 19 **/.obsidian 20 **/.trash 21 **/bug_notes.md
··· 16 17 **/.claude/settings.local.json 18 .workspaces/ 19 + **/**.wasm 20 **/.obsidian 21 **/.trash 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 font-family: var(--font-ui); 7 } 8 9 a { 10 color: var(--color-link); 11 text-decoration: none;
··· 6 font-family: var(--font-ui); 7 } 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 + 19 a { 20 color: var(--color-link); 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 const ret = arg0.versions; 443 return ret; 444 }; 445 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 446 // Cast intrinsic for `Ref(String) -> Externref`. 447 const ret = getStringFromWasm0(arg0, arg1); 448 return ret; 449 }; 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 imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { 456 // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. 457 const ret = getArrayU8FromWasm0(arg0, arg1); 458 return ret; 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`. 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 return ret; 464 };
··· 442 const ret = arg0.versions; 443 return ret; 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 + }; 450 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 451 // Cast intrinsic for `Ref(String) -> Externref`. 452 const ret = getStringFromWasm0(arg0, arg1); 453 return ret; 454 }; 455 imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { 456 // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. 457 const ret = getArrayU8FromWasm0(arg0, arg1); 458 return ret; 459 }; 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 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 return ret; 464 };
+15 -15
crates/weaver-app/public/embed_worker.js
··· 236 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 237 } 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 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 244 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 245 } 246 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { ··· 612 const ret = getStringFromWasm0(arg0, arg1); 613 return ret; 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`. 622 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 return ret; 624 }; 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`. 627 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 return ret; 629 }; 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`. 632 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_); 633 return ret; 634 }; 635 imports.wbg.__wbindgen_init_externref_table = function() {
··· 236 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 237 } 238 239 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 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 } 246 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { ··· 612 const ret = getStringFromWasm0(arg0, arg1); 613 return ret; 614 }; 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`. 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_____); 618 return ret; 619 }; 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`. 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______); 623 return ret; 624 }; 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`. 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 return ret; 634 }; 635 imports.wbg.__wbindgen_init_externref_table = function() {
+4 -2
crates/weaver-app/src/env.rs
··· 15 #[allow(unused)] 16 pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; 17 #[allow(unused)] 18 - pub const WEAVER_TOS_URI: &'static str = ""; 19 #[allow(unused)] 20 - pub const WEAVER_PRIVACY_POLICY_URI: &'static str = "";
··· 15 #[allow(unused)] 16 pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; 17 #[allow(unused)] 18 + pub const WEAVER_TOS_URI: &'static str = "https://alpha.weaver.sh/tos"; 19 #[allow(unused)] 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 use config::{Config, OAuthConfig}; 36 #[allow(unused)] 37 use views::{ 38 - Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, Navbar, NewDraft, Notebook, 39 - NotebookEntryByRkey, NotebookEntryEdit, NotebookIndex, NotebookPage, RecordIndex, RecordPage, 40 - StandaloneEntry, StandaloneEntryEdit, 41 }; 42 43 #[derive(Debug, Clone, Routable, PartialEq)] ··· 48 Home {}, 49 #[route("/editor?:entry")] 50 Editor { entry: Option<String> }, 51 #[layout(ErrorLayout)] 52 #[nest("/record")] 53 #[layout(RecordIndex)]
··· 35 use config::{Config, OAuthConfig}; 36 #[allow(unused)] 37 use views::{ 38 + AboutPage, Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, Navbar, NewDraft, 39 + Notebook, NotebookEntryByRkey, NotebookEntryEdit, NotebookIndex, NotebookPage, PrivacyPage, 40 + RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit, TermsPage, 41 }; 42 43 #[derive(Debug, Clone, Routable, PartialEq)] ··· 48 Home {}, 49 #[route("/editor?:entry")] 50 Editor { entry: Option<String> }, 51 + #[route("/about")] 52 + AboutPage {}, 53 + #[route("/tos")] 54 + TermsPage {}, 55 + #[route("/privacy")] 56 + PrivacyPage {}, 57 #[layout(ErrorLayout)] 58 #[nest("/record")] 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 38 mod invites; 39 pub use invites::InvitesPage;
··· 37 38 mod invites; 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 use crate::components::login::LoginModal; 5 use crate::data::{use_get_handle, use_load_handle}; 6 use crate::fetch::Fetcher; 7 use dioxus::prelude::*; 8 use dioxus_primitives::toast::{ToastOptions, use_toast}; 9 use jacquard::types::ident::AtIdentifier; ··· 67 document::Link { rel: "stylesheet", href: CARDS_BASE_CSS } 68 document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } 69 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 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 - }, 128 class: "breadcrumb", 129 - "{book_title}" 130 } 131 } 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}" 142 } 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}" 154 } 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 { 161 rsx! { 162 span { class:"breadcrumb-separator"," > "} 163 Link { ··· 170 Link { 171 to: Route::NotebookIndex { 172 ident: ident.clone(), 173 - book_title: notebook.clone() 174 }, 175 class: "breadcrumb", 176 - "{notebook}" 177 } 178 } 179 - } else { 180 rsx! { 181 span { class:"breadcrumb-separator"," > "} 182 - span { class:"breadcrumb breadcrumb-current","@{handle}"} 183 } 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}" 195 } 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}" 207 } 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}" 220 } 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}" 229 } 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}" 242 } 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}" 251 } 252 - } 253 - }, 254 - _ => rsx! {}, 255 } 256 - } 257 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" 270 } 271 } 272 - } 273 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" } 285 } 286 287 } 288 - LoginModal { 289 - open: show_login_modal 290 - } 291 } 292 } 293 - 294 - Outlet::<Route> {} 295 } 296 } 297
··· 4 use crate::components::login::LoginModal; 5 use crate::data::{use_get_handle, use_load_handle}; 6 use crate::fetch::Fetcher; 7 + use crate::views::Footer; 8 use dioxus::prelude::*; 9 use dioxus_primitives::toast::{ToastOptions, use_toast}; 10 use jacquard::types::ident::AtIdentifier; ··· 68 document::Link { rel: "stylesheet", href: CARDS_BASE_CSS } 69 document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } 70 document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } 71 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: "/", 84 class: "breadcrumb", 85 + "Home" 86 } 87 } 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}"} 98 } 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}"} 112 } 113 + }, 114 + Route::EntryPage { ident, book_title, .. } => { 115 + let route_handle=route_handle.read().clone(); 116 + let handle=route_handle.unwrap_or(ident.clone()); 117 rsx! { 118 span { class:"breadcrumb-separator"," > "} 119 Link { ··· 126 Link { 127 to: Route::NotebookIndex { 128 ident: ident.clone(), 129 + book_title: book_title.clone() 130 }, 131 class: "breadcrumb", 132 + "{book_title}" 133 } 134 } 135 + }, 136 + Route::DraftsList { ident } => { 137 + let route_handle = route_handle.read().clone(); 138 + let handle = route_handle.unwrap_or(ident.clone()); 139 rsx! { 140 span { class:"breadcrumb-separator"," > "} 141 + Link { 142 + to: Route::RepositoryIndex { ident: ident.clone() 143 + }, 144 + class: "breadcrumb","@{handle}" 145 + } 146 } 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 + } 158 } 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 + } 187 } 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 + } 199 } 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 + } 211 } 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 + } 233 } 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 + } 255 } 256 + }, 257 + _ => rsx! {}, 258 + } 259 } 260 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 + } 274 } 275 } 276 277 + if auth_state.read().is_authenticated() { 278 + if let Some(did) = &auth_state.read().did { 279 + AuthButton { did: did.clone() } 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 + } 289 290 + } 291 + LoginModal { 292 + open: show_login_modal 293 + } 294 } 295 + } 296 + 297 + main { class: "app-main", 298 + Outlet::<Route> {} 299 } 300 + 301 + Footer {} 302 } 303 } 304 } 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 + }