From 1fff23866e2df7662b2daf498452bcd9a565b421 Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 9 Dec 2025 13:29:23 -0500 Subject: [PATCH] static pages for about, etc. --- .gitignore | 1 + crates/weaver-app/assets/about.md | 38 ++ crates/weaver-app/assets/privacy.md | 27 ++ crates/weaver-app/assets/styling/footer.css | 93 +++++ crates/weaver-app/assets/styling/main.css | 10 + crates/weaver-app/assets/terms.md | 19 + crates/weaver-app/public/editor_worker.js | 14 +- crates/weaver-app/public/embed_worker.js | 30 +- crates/weaver-app/src/env.rs | 6 +- crates/weaver-app/src/lib.rs | 12 +- crates/weaver-app/src/views/footer.rs | 171 +++++++++ crates/weaver-app/src/views/mod.rs | 6 + crates/weaver-app/src/views/navbar.rs | 376 ++++++++++---------- crates/weaver-app/src/views/static_page.rs | 72 ++++ 14 files changed, 664 insertions(+), 211 deletions(-) create mode 100644 crates/weaver-app/assets/about.md create mode 100644 crates/weaver-app/assets/privacy.md create mode 100644 crates/weaver-app/assets/styling/footer.css create mode 100644 crates/weaver-app/assets/terms.md create mode 100644 crates/weaver-app/src/views/footer.rs create mode 100644 crates/weaver-app/src/views/static_page.rs diff --git a/.gitignore b/.gitignore index c4e6e44..92f76c1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ grant_proposal.md **/.claude/settings.local.json .workspaces/ +**/**.wasm **/.obsidian **/.trash **/bug_notes.md diff --git a/crates/weaver-app/assets/about.md b/crates/weaver-app/assets/about.md new file mode 100644 index 0000000..55ea10b --- /dev/null +++ b/crates/weaver-app/assets/about.md @@ -0,0 +1,38 @@ +# About Weaver + +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. + +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. + +## Current Features + +- Extended Markdown with wikilinks, embedded content, and math support. +- Obsidian-style hybrid editor that is easy to use +- Real-time or asynchronous collaboration between users on notebook entries +- Themeable notebooks (not directly accessible in the UI yet) +- User profiles (also not directly accessible in the UI yet, interoperability with Bluesky and Tangled profiles) + +## Planned Features + +- Comprehensive suite of social and discovery features + - User tagging and lists + - Subscriptions and notifications (user, notebook, or tag-level) +- Private drafts and publishing (off-protocol until atproto officially supports private on-protocol data) + - Technically the former already exists, but affordances are bad +- Integration of payment platforms + +# A personal note from the creator + +I'm a writer and a musician as well as an engineer. + +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. + +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/). + +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. + +### Contribution + +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). + +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). diff --git a/crates/weaver-app/assets/privacy.md b/crates/weaver-app/assets/privacy.md new file mode 100644 index 0000000..52c6514 --- /dev/null +++ b/crates/weaver-app/assets/privacy.md @@ -0,0 +1,27 @@ +# Privacy Policy + +*Last updated: December 2025* + +This privacy policy is a placeholder. A proper privacy policy will be added before Weaver moves into beta. + +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). + +## Data Collection + +Weaver itself does not collect personal data. However: + +- **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. +- **Bluesky**: If you use a Bluesky-operated PDS, Bluesky's privacy policy applies to that data. +- **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. + +## Cookies + +Weaver uses local storage to maintain your authentication session and unsynced draft state. No tracking cookies are used. + +## Analytics + +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. + +## Contact + +For privacy concerns, please open an issue on the [project repository](https://tangled.org/nonbinary.computer/weaver/issues) or email contact(at)weaver.sh. diff --git a/crates/weaver-app/assets/styling/footer.css b/crates/weaver-app/assets/styling/footer.css new file mode 100644 index 0000000..843b56d --- /dev/null +++ b/crates/weaver-app/assets/styling/footer.css @@ -0,0 +1,93 @@ +/* Site footer - two tiers: minimal (always) and full (shell pages only) */ + +.site-footer { + display: flex; + justify-content: center; + padding: 1.5rem 1rem; + margin-top: auto; + border-top: 1px solid var(--color-border); +} + +.footer-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + max-width: 1200px; + width: 100%; +} + +.footer-links { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 1rem; +} + +.footer-link { + display: flex; + align-items: center; + gap: 0.35rem; + color: var(--color-muted); + text-decoration: none; + font-size: 0.8125rem; + font-family: var(--font-ui); + transition: color 0.15s ease; +} + +.footer-link:hover { + color: var(--color-text); +} + +.footer-link svg { + width: 14px; + height: 14px; +} + +.footer-separator { + color: var(--color-muted); + user-select: none; + font-size: 0.75rem; +} + +/* Minimal footer for user content pages */ +.site-footer-minimal { + display: flex; + justify-content: center; + padding: 1rem; + margin-top: auto; + border-top: 1px solid var(--color-border); +} + +.site-footer-minimal .footer-links { + gap: 0.75rem; +} + +.site-footer-minimal .footer-link { + font-size: 0.75rem; + color: var(--color-subtle); +} + +.site-footer-minimal .footer-link:hover { + color: var(--color-muted); +} + +.site-footer-minimal .footer-separator { + font-size: 0.625rem; +} + +/* Mobile: stack vertically on full footer */ +@media (max-width: 480px) { + .site-footer .footer-content { + gap: 0.5rem; + } + + .site-footer .footer-links { + gap: 0.5rem 0.75rem; + } + + .site-footer .footer-separator { + display: none; + } +} diff --git a/crates/weaver-app/assets/styling/main.css b/crates/weaver-app/assets/styling/main.css index 011bd60..c600554 100644 --- a/crates/weaver-app/assets/styling/main.css +++ b/crates/weaver-app/assets/styling/main.css @@ -6,6 +6,16 @@ body { font-family: var(--font-ui); } +.app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app-main { + flex: 1; +} + a { color: var(--color-link); text-decoration: none; diff --git a/crates/weaver-app/assets/terms.md b/crates/weaver-app/assets/terms.md new file mode 100644 index 0000000..57581a9 --- /dev/null +++ b/crates/weaver-app/assets/terms.md @@ -0,0 +1,19 @@ +# Terms of Service + +*Last updated: December 2025* + +These terms are a preliminary placeholder and will be updated before public beta release. + +## Use at Your Own Risk + +Weaver is currently in alpha. Features will change, there are bugs, and data loss is possible. + +## Content + +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. + +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. + +## No Warranty + +This software is provided "as is" without warranty of any kind. diff --git a/crates/weaver-app/public/editor_worker.js b/crates/weaver-app/public/editor_worker.js index cd3fe9d..4ff0b3c 100644 --- a/crates/weaver-app/public/editor_worker.js +++ b/crates/weaver-app/public/editor_worker.js @@ -442,23 +442,23 @@ let wasm_bindgen; const ret = arg0.versions; return ret; }; + imports.wbg.__wbindgen_cast_1511eb630aa228f5 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 940, function: Function { arguments: [Externref], shim_idx: 941, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + 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_____); + return ret; + }; imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { // Cast intrinsic for `Ref(String) -> Externref`. const ret = getStringFromWasm0(arg0, arg1); return ret; }; - imports.wbg.__wbindgen_cast_3fda284bdcf7704e = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 941, function: Function { arguments: [Externref], shim_idx: 942, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - 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_____); - return ret; - }; imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. const ret = getArrayU8FromWasm0(arg0, arg1); return ret; }; - imports.wbg.__wbindgen_cast_e969152242ffebd9 = function(arg0, arg1) { - // 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`. + imports.wbg.__wbindgen_cast_ce6245619dc560a7 = function(arg0, arg1) { + // 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`. 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_____); return ret; }; diff --git a/crates/weaver-app/public/embed_worker.js b/crates/weaver-app/public/embed_worker.js index 0e16d13..ad35220 100644 --- a/crates/weaver-app/public/embed_worker.js +++ b/crates/weaver-app/public/embed_worker.js @@ -236,14 +236,14 @@ let wasm_bindgen; wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); } - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); - } - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); + } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); } @@ -612,26 +612,26 @@ let wasm_bindgen; const ret = getStringFromWasm0(arg0, arg1); return ret; }; - imports.wbg.__wbindgen_cast_82f5386d361eee3f = function(arg0, arg1) { - // 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`. - 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_____); - return ret; - }; - imports.wbg.__wbindgen_cast_bc7590ff5cbfa2f9 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 2214, function: Function { arguments: [Externref], shim_idx: 2215, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + imports.wbg.__wbindgen_cast_36dddc5933837ecc = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 2216, function: Function { arguments: [Externref], shim_idx: 2217, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 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_____); return ret; }; - imports.wbg.__wbindgen_cast_c2fcf804dba34eff = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 1186, function: Function { arguments: [], shim_idx: 1187, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + imports.wbg.__wbindgen_cast_3eeb44a0158730cb = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 1188, function: Function { arguments: [], shim_idx: 1189, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); return ret; }; - imports.wbg.__wbindgen_cast_fbdf1c7b4b56bb43 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 1446, function: Function { arguments: [], shim_idx: 1447, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + imports.wbg.__wbindgen_cast_b5fa8180acb99032 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 1448, function: Function { arguments: [], shim_idx: 1449, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 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_); return ret; }; + imports.wbg.__wbindgen_cast_d976f3c9b97a6409 = function(arg0, arg1) { + // 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`. + 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_____); + return ret; + }; imports.wbg.__wbindgen_init_externref_table = function() { const table = wasm.__wbindgen_externrefs; const offset = table.grow(4); diff --git a/crates/weaver-app/src/env.rs b/crates/weaver-app/src/env.rs index f8a1ad7..aa0c24e 100644 --- a/crates/weaver-app/src/env.rs +++ b/crates/weaver-app/src/env.rs @@ -15,6 +15,8 @@ pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; #[allow(unused)] pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; #[allow(unused)] -pub const WEAVER_TOS_URI: &'static str = ""; +pub const WEAVER_TOS_URI: &'static str = "https://alpha.weaver.sh/tos"; #[allow(unused)] -pub const WEAVER_PRIVACY_POLICY_URI: &'static str = ""; +pub const WEAVER_PRIVACY_POLICY_URI: &'static str = "https://alpha.weaver.sh/privacy"; +#[allow(unused)] +pub const WEAVER_OWNER_DID: &'static str = "did:plc:yfvwmnlztr4dwkb7hwz55r2g"; diff --git a/crates/weaver-app/src/lib.rs b/crates/weaver-app/src/lib.rs index 6eb3693..e71aafe 100644 --- a/crates/weaver-app/src/lib.rs +++ b/crates/weaver-app/src/lib.rs @@ -35,9 +35,9 @@ use components::{EntryPage, Repository, RepositoryIndex}; use config::{Config, OAuthConfig}; #[allow(unused)] use views::{ - Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, Navbar, NewDraft, Notebook, - NotebookEntryByRkey, NotebookEntryEdit, NotebookIndex, NotebookPage, RecordIndex, RecordPage, - StandaloneEntry, StandaloneEntryEdit, + AboutPage, Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, Navbar, NewDraft, + Notebook, NotebookEntryByRkey, NotebookEntryEdit, NotebookIndex, NotebookPage, PrivacyPage, + RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit, TermsPage, }; #[derive(Debug, Clone, Routable, PartialEq)] @@ -48,6 +48,12 @@ pub enum Route { Home {}, #[route("/editor?:entry")] Editor { entry: Option }, + #[route("/about")] + AboutPage {}, + #[route("/tos")] + TermsPage {}, + #[route("/privacy")] + PrivacyPage {}, #[layout(ErrorLayout)] #[nest("/record")] #[layout(RecordIndex)] diff --git a/crates/weaver-app/src/views/footer.rs b/crates/weaver-app/src/views/footer.rs new file mode 100644 index 0000000..8abdeac --- /dev/null +++ b/crates/weaver-app/src/views/footer.rs @@ -0,0 +1,171 @@ +use crate::Route; +use crate::components::{BskyIcon, TangledIcon}; +use dioxus::prelude::*; +use jacquard::types::string::AtIdentifier; + +const FOOTER_CSS: Asset = asset!("/assets/styling/footer.css"); + +const TANGLED_REPO_URL: &str = "https://tangled.org/nonbinary.computer/weaver"; +const TANGLED_ISSUES_URL: &str = "https://tangled.org/nonbinary.computer/weaver/issues"; +const BSKY_URL: &str = "https://bsky.app/profile/nonbinary.computer"; +const GITHUB_SPONSORS_URL: &str = "https://github.com/sponsors/orual"; + +/// Determines if the current route should show the full footer or just the minimal version. +/// Full footer shows on shell pages (Home, Editor) and on owner's content pages. +fn should_show_full_footer(route: &Route) -> bool { + match route { + // Shell pages: always show full footer + Route::Home {} + | Route::Editor { .. } + | Route::AboutPage {} + | Route::TermsPage {} + | Route::PrivacyPage {} => true, + + // Callback is transient, minimal is fine + Route::Callback { .. } => false, + + // Record viewer shows arbitrary user content + Route::RecordPage { .. } => false, + + // User content pages: check if owner + Route::RepositoryIndex { ident } + | Route::DraftsList { ident } + | Route::DraftEdit { ident, .. } + | Route::NewDraft { ident, .. } + | Route::InvitesPage { ident } + | Route::StandaloneEntry { ident, .. } + | Route::StandaloneEntryEdit { ident, .. } + | Route::NotebookIndex { ident, .. } + | Route::EntryPage { ident, .. } + | Route::NotebookEntryByRkey { ident, .. } + | Route::NotebookEntryEdit { ident, .. } => is_owner_ident(ident), + } +} + +/// Check if the given identifier matches the site owner DID. +fn is_owner_ident(ident: &AtIdentifier<'static>) -> bool { + let owner_did = crate::env::WEAVER_OWNER_DID; + if owner_did.is_empty() { + return false; + } + + match ident { + AtIdentifier::Did(did) => did.as_ref() == owner_did, + // Could resolve handle to DID, but keeping it simple for now + AtIdentifier::Handle(_) => false, + } +} + +#[component] +pub fn Footer() -> Element { + let route = use_route::(); + let show_full = should_show_full_footer(&route); + + rsx! { + document::Link { rel: "stylesheet", href: FOOTER_CSS } + + if show_full { + footer { class: "site-footer", + div { class: "footer-content", + div { class: "footer-links", + a { + href: "{crate::env::WEAVER_APP_HOST}/about", + class: "footer-link", + "About" + } + + span { class: "footer-separator", "|" } + + a { + href: crate::env::WEAVER_TOS_URI, + class: "footer-link", + "Terms" + } + + span { class: "footer-separator", "|" } + + a { + href: crate::env::WEAVER_PRIVACY_POLICY_URI, + class: "footer-link", + "Privacy" + } + + span { class: "footer-separator", "|" } + + a { + href: TANGLED_REPO_URL, + class: "footer-link", + target: "_blank", + rel: "noopener", + TangledIcon { + height: Some(14), + width: Some(14), + } + "Source" + } + + span { class: "footer-separator", "|" } + + a { + href: TANGLED_ISSUES_URL, + class: "footer-link", + target: "_blank", + rel: "noopener", + "Report Bug" + } + + span { class: "footer-separator", "|" } + + a { + href: BSKY_URL, + class: "footer-link", + target: "_blank", + rel: "noopener", + BskyIcon { + height: Some(14), + width: Some(14), + } + "Bluesky" + } + + span { class: "footer-separator", "|" } + + a { + href: GITHUB_SPONSORS_URL, + class: "footer-link", + target: "_blank", + rel: "noopener", + "Sponsor" + } + } + } + } + } else { + footer { class: "site-footer-minimal", + div { class: "footer-links", + a { + href: TANGLED_REPO_URL, + class: "footer-link", + target: "_blank", + rel: "noopener", + TangledIcon { + height: Some(12), + width: Some(12), + } + "Source" + } + + span { class: "footer-separator", "|" } + + a { + href: TANGLED_ISSUES_URL, + class: "footer-link", + target: "_blank", + rel: "noopener", + "Report Bug" + } + } + } + } + } +} diff --git a/crates/weaver-app/src/views/mod.rs b/crates/weaver-app/src/views/mod.rs index ee6abdd..cc57902 100644 --- a/crates/weaver-app/src/views/mod.rs +++ b/crates/weaver-app/src/views/mod.rs @@ -37,3 +37,9 @@ pub use entry::{NotebookEntryByRkey, StandaloneEntry}; mod invites; pub use invites::InvitesPage; + +mod footer; +pub use footer::Footer; + +mod static_page; +pub use static_page::{AboutPage, PrivacyPage, TermsPage}; diff --git a/crates/weaver-app/src/views/navbar.rs b/crates/weaver-app/src/views/navbar.rs index 552fbda..de2be6e 100644 --- a/crates/weaver-app/src/views/navbar.rs +++ b/crates/weaver-app/src/views/navbar.rs @@ -4,6 +4,7 @@ use crate::components::button::{Button, ButtonVariant}; use crate::components::login::LoginModal; use crate::data::{use_get_handle, use_load_handle}; use crate::fetch::Fetcher; +use crate::views::Footer; use dioxus::prelude::*; use dioxus_primitives::toast::{ToastOptions, use_toast}; use jacquard::types::ident::AtIdentifier; @@ -67,97 +68,52 @@ pub fn Navbar() -> Element { document::Link { rel: "stylesheet", href: CARDS_BASE_CSS } document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } - div { - id: "navbar", - nav { class: "breadcrumbs", - // On home page: show profile link if authenticated, otherwise "Home" - match (&route, &auth_state.read().did) { - (Route::Home {}, Some(did)) => rsx! { - ProfileBreadcrumb { did: did.clone() } - }, - _ => rsx! { - a { - href: "/", - class: "breadcrumb", - "Home" - } - } - } - // Show repository breadcrumb if we're on a repository page - match &route { - Route::RepositoryIndex { ident } => { - let route_handle = route_handle.read().clone(); - let handle = route_handle.unwrap_or(ident.clone()); - rsx! { - span { class:"breadcrumb-separator"," > "} - span { class:"breadcrumb breadcrumb-current","@{handle}"} - } - }, - Route::NotebookIndex{ ident, book_title } => { - let route_handle = route_handle.read().clone(); - let handle = route_handle.unwrap_or(ident.clone()); - rsx! { - span { class:"breadcrumb-separator"," > " } - Link { - to: Route::RepositoryIndex { ident: ident.clone() - }, - class: "breadcrumb","@{handle}" - } - span{ class: "breadcrumb-separator"," > "} - span{ class: "breadcrumb breadcrumb-current","{book_title}"} - } - }, - Route::EntryPage { ident, book_title, .. } => { - let route_handle=route_handle.read().clone(); - let handle=route_handle.unwrap_or(ident.clone()); - rsx! { - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::RepositoryIndex { - ident:ident.clone() - }, - class:"breadcrumb","@{handle}" - } - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::NotebookIndex { - ident: ident.clone(), - book_title: book_title.clone() - }, + div { class: "app-shell", + div { + id: "navbar", + nav { class: "breadcrumbs", + // On home page: show profile link if authenticated, otherwise "Home" + match (&route, &auth_state.read().did) { + (Route::Home {}, Some(did)) => rsx! { + ProfileBreadcrumb { did: did.clone() } + }, + _ => rsx! { + a { + href: "/", class: "breadcrumb", - "{book_title}" + "Home" } } - }, - Route::DraftsList { ident } => { - let route_handle = route_handle.read().clone(); - let handle = route_handle.unwrap_or(ident.clone()); - rsx! { - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::RepositoryIndex { ident: ident.clone() - }, - class: "breadcrumb","@{handle}" + } + + // Show repository breadcrumb if we're on a repository page + match &route { + Route::RepositoryIndex { ident } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + span { class:"breadcrumb breadcrumb-current","@{handle}"} } - } - }, - Route::DraftEdit { ident, .. } => { - let route_handle = route_handle.read().clone(); - let handle = route_handle.unwrap_or(ident.clone()); - rsx! { - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::RepositoryIndex { ident: ident.clone() - }, - class: "breadcrumb","@{handle}" + }, + Route::NotebookIndex{ ident, book_title } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > " } + Link { + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" + } + span{ class: "breadcrumb-separator"," > "} + span{ class: "breadcrumb breadcrumb-current","{book_title}"} } - } - }, - Route::NewDraft { ident, notebook } => { - let route_handle = route_handle.read().clone(); - let handle = route_handle.unwrap_or(ident.clone()); - if let Some(notebook) = notebook { + }, + Route::EntryPage { ident, book_title, .. } => { + let route_handle=route_handle.read().clone(); + let handle=route_handle.unwrap_or(ident.clone()); rsx! { span { class:"breadcrumb-separator"," > "} Link { @@ -170,128 +126,180 @@ pub fn Navbar() -> Element { Link { to: Route::NotebookIndex { ident: ident.clone(), - book_title: notebook.clone() + book_title: book_title.clone() }, class: "breadcrumb", - "{notebook}" + "{book_title}" } } - } else { + }, + Route::DraftsList { ident } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); rsx! { span { class:"breadcrumb-separator"," > "} - span { class:"breadcrumb breadcrumb-current","@{handle}"} + Link { + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" + } } - } - }, - Route::StandaloneEntry { ident, .. } => { - let route_handle = route_handle.read().clone(); - let handle = route_handle.unwrap_or(ident.clone()); - rsx! { - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::RepositoryIndex { ident: ident.clone() - }, - class: "breadcrumb","@{handle}" + }, + Route::DraftEdit { ident, .. } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" + } } - } - }, - Route::StandaloneEntryEdit { ident, .. } => { - let route_handle = route_handle.read().clone(); - let handle = route_handle.unwrap_or(ident.clone()); - rsx! { - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::RepositoryIndex { ident: ident.clone() - }, - class: "breadcrumb","@{handle}" + }, + Route::NewDraft { ident, notebook } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + if let Some(notebook) = notebook { + rsx! { + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::RepositoryIndex { + ident:ident.clone() + }, + class:"breadcrumb","@{handle}" + } + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::NotebookIndex { + ident: ident.clone(), + book_title: notebook.clone() + }, + class: "breadcrumb", + "{notebook}" + } + } + } else { + rsx! { + span { class:"breadcrumb-separator"," > "} + span { class:"breadcrumb breadcrumb-current","@{handle}"} + } } - } - }, - Route::NotebookEntryByRkey { ident, book_title, .. } => { - let route_handle=route_handle.read().clone(); - let handle=route_handle.unwrap_or(ident.clone()); - rsx! { - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::RepositoryIndex { - ident:ident.clone() - }, - class:"breadcrumb","@{handle}" + }, + Route::StandaloneEntry { ident, .. } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" + } } - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::NotebookIndex { - ident: ident.clone(), - book_title: book_title.clone() - }, - class: "breadcrumb", - "{book_title}" + }, + Route::StandaloneEntryEdit { ident, .. } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" + } } - } - }, - Route::NotebookEntryEdit { ident, book_title, .. } => { - let route_handle=route_handle.read().clone(); - let handle=route_handle.unwrap_or(ident.clone()); - rsx! { - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::RepositoryIndex { - ident:ident.clone() - }, - class:"breadcrumb","@{handle}" + }, + Route::NotebookEntryByRkey { ident, book_title, .. } => { + let route_handle=route_handle.read().clone(); + let handle=route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::RepositoryIndex { + ident:ident.clone() + }, + class:"breadcrumb","@{handle}" + } + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::NotebookIndex { + ident: ident.clone(), + book_title: book_title.clone() + }, + class: "breadcrumb", + "{book_title}" + } } - span { class:"breadcrumb-separator"," > "} - Link { - to: Route::NotebookIndex { - ident: ident.clone(), - book_title: book_title.clone() - }, - class: "breadcrumb", - "{book_title}" + }, + Route::NotebookEntryEdit { ident, book_title, .. } => { + let route_handle=route_handle.read().clone(); + let handle=route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::RepositoryIndex { + ident:ident.clone() + }, + class:"breadcrumb","@{handle}" + } + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::NotebookIndex { + ident: ident.clone(), + book_title: book_title.clone() + }, + class: "breadcrumb", + "{book_title}" + } } - } - }, - _ => rsx! {}, + }, + _ => rsx! {}, + } } - } - // Tool links (show on home page) - if matches!(route, Route::Home {}) { - nav { class: "nav-tools", - Link { - to: Route::RecordPage { uri: vec![] }, - class: "nav-tool-link", - "Record Viewer" - } - Link { - to: Route::Editor { entry: None }, - class: "nav-tool-link", - "Editor" + // Tool links (show on home page) + if matches!(route, Route::Home {}) { + nav { class: "nav-tools", + Link { + to: Route::RecordPage { uri: vec![] }, + class: "nav-tool-link", + "Record Viewer" + } + Link { + to: Route::Editor { entry: None }, + class: "nav-tool-link", + "Editor" + } } } - } - if auth_state.read().is_authenticated() { - if let Some(did) = &auth_state.read().did { - AuthButton { did: did.clone() } - } - } else { - div { - class: "auth-button", - Button { - variant: ButtonVariant::Ghost, - onclick: move |_| show_login_modal.set(true), - span { class: "auth-handle", "Sign In" } + if auth_state.read().is_authenticated() { + if let Some(did) = &auth_state.read().did { + AuthButton { did: did.clone() } } + } else { + div { + class: "auth-button", + Button { + variant: ButtonVariant::Ghost, + onclick: move |_| show_login_modal.set(true), + span { class: "auth-handle", "Sign In" } + } - } - LoginModal { - open: show_login_modal + } + LoginModal { + open: show_login_modal + } } } - } - Outlet:: {} + main { class: "app-main", + Outlet:: {} + } + + Footer {} + } } } diff --git a/crates/weaver-app/src/views/static_page.rs b/crates/weaver-app/src/views/static_page.rs new file mode 100644 index 0000000..76842cb --- /dev/null +++ b/crates/weaver-app/src/views/static_page.rs @@ -0,0 +1,72 @@ +use crate::components::css::DefaultNotebookCss; +use crate::components::ENTRY_CSS; +use dioxus::prelude::*; +use weaver_renderer::atproto::ClientWriter; + +const ABOUT_MD: &str = include_str!("../../assets/about.md"); +const TERMS_MD: &str = include_str!("../../assets/terms.md"); +const PRIVACY_MD: &str = include_str!("../../assets/privacy.md"); + +fn render_markdown(content: &str) -> String { + let parser = markdown_weaver::Parser::new_ext(content, weaver_renderer::default_md_options()); + let mut html = String::new(); + let _ = ClientWriter::<_, _, ()>::new(parser, &mut html).run(); + html +} + +#[derive(Clone, Copy, PartialEq)] +pub enum StaticPageKind { + About, + Terms, + Privacy, +} + +impl StaticPageKind { + fn content(&self) -> &'static str { + match self { + StaticPageKind::About => ABOUT_MD, + StaticPageKind::Terms => TERMS_MD, + StaticPageKind::Privacy => PRIVACY_MD, + } + } + + fn title(&self) -> &'static str { + match self { + StaticPageKind::About => "About", + StaticPageKind::Terms => "Terms of Service", + StaticPageKind::Privacy => "Privacy Policy", + } + } +} + +#[component] +pub fn StaticPage(kind: StaticPageKind) -> Element { + let html = render_markdown(kind.content()); + + rsx! { + DefaultNotebookCss {} + document::Link { rel: "stylesheet", href: ENTRY_CSS } + document::Title { "{kind.title()} - Weaver" } + + div { class: "static-page", + article { class: "entry notebook-content", + dangerous_inner_html: "{html}" + } + } + } +} + +#[component] +pub fn AboutPage() -> Element { + rsx! { StaticPage { kind: StaticPageKind::About } } +} + +#[component] +pub fn TermsPage() -> Element { + rsx! { StaticPage { kind: StaticPageKind::Terms } } +} + +#[component] +pub fn PrivacyPage() -> Element { + rsx! { StaticPage { kind: StaticPageKind::Privacy } } +} -- knotmirror 0.0.1