atproto blogging
1//! Weaver App library.
2#[allow(unused)]
3use dioxus::{CapturedError, prelude::*};
4
5#[cfg(feature = "fullstack-server")]
6pub use dioxus::fullstack::FullstackContext;
7use jacquard::oauth::{client::OAuthClient, session::ClientData};
8#[allow(unused)]
9use jacquard::{
10 smol_str::SmolStr,
11 types::{cid::Cid, string::AtIdentifier},
12};
13use std::sync::LazyLock;
14
15pub mod auth;
16#[cfg(feature = "server")]
17pub mod blobcache;
18pub mod cache_impl;
19pub mod collab_context;
20pub mod components;
21pub mod config;
22pub mod data;
23pub mod env;
24pub mod fetch;
25pub mod host_mode;
26#[cfg(feature = "server")]
27pub mod og;
28pub mod perf;
29pub mod record_utils;
30pub mod service_worker;
31
32pub mod custom_domain_app;
33#[cfg(feature = "server")]
34pub mod middleware;
35pub mod subdomain_app;
36pub mod views;
37#[cfg(feature = "server")]
38pub mod well_known;
39
40pub use custom_domain_app::{CustomDomainApp, CustomDomainRoute};
41pub use host_mode::{CustomDomainContext, HostContext, LinkMode, SubdomainContext};
42pub use subdomain_app::{SubdomainApp, SubdomainRoute};
43
44use auth::{AuthState, AuthStore};
45use components::{EntryPage, Repository, RepositoryIndex};
46use config::{Config, OAuthConfig};
47#[allow(unused)]
48use views::{
49 AboutPage, Callback, DraftEdit, DraftsList, Editor, Home, InvitesPage, LeafletEntry,
50 LeafletEntryNsid, Navbar, NewDraft, Notebook, NotebookEntryByRkey, NotebookEntryEdit,
51 NotebookIndex, NotebookPage, NotebookSettings, PcktEntry, PcktEntryBlogNsid, PcktEntryNsid,
52 PrivacyPage, RecordIndex, RecordPage, StandaloneEntry, StandaloneEntryEdit,
53 StandaloneEntryNsid, TermsPage, WhiteWindEntry, WhiteWindEntryNsid,
54};
55
56#[derive(Debug, Clone, Routable, PartialEq)]
57#[rustfmt::skip]
58pub enum Route {
59 #[layout(Navbar)]
60 #[route("/")]
61 Home {},
62 #[route("/editor?:entry")]
63 Editor { entry: Option<String> },
64 #[route("/about")]
65 AboutPage {},
66 #[route("/tos")]
67 TermsPage {},
68 #[route("/privacy")]
69 PrivacyPage {},
70 #[layout(ErrorLayout)]
71 #[nest("/record")]
72 #[layout(RecordIndex)]
73 #[route("/:..uri")]
74 RecordPage { uri: Vec<String> },
75 #[end_layout]
76 #[end_nest]
77 #[route("/callback?:state&:iss&:code")]
78 Callback { state: SmolStr, iss: SmolStr, code: SmolStr },
79 #[nest("/:ident")]
80 #[layout(Repository)]
81 #[route("/")]
82 RepositoryIndex { ident: AtIdentifier<'static> },
83 // Drafts routes (before /:book_title to avoid capture)
84 #[route("/drafts")]
85 DraftsList { ident: AtIdentifier<'static> },
86 #[route("/drafts/:tid")]
87 DraftEdit { ident: AtIdentifier<'static>, tid: SmolStr },
88 #[route("/new?:notebook")]
89 NewDraft { ident: AtIdentifier<'static>, notebook: Option<SmolStr> },
90 // Collaboration invites
91 #[route("/invites")]
92 InvitesPage { ident: AtIdentifier<'static> },
93 // Standalone entry routes
94 #[route("/e/:rkey")]
95 StandaloneEntry { ident: AtIdentifier<'static>, rkey: SmolStr },
96 #[route("/sh.weaver.notebook.entry/:rkey")]
97 StandaloneEntryNsid { ident: AtIdentifier<'static>, rkey: SmolStr },
98 #[route("/e/:rkey/edit")]
99 StandaloneEntryEdit { ident: AtIdentifier<'static>, rkey: SmolStr },
100 // External blog routes (short paths)
101 #[route("/w/:rkey")]
102 WhiteWindEntry { ident: AtIdentifier<'static>, rkey: SmolStr },
103 #[route("/l/:rkey")]
104 LeafletEntry { ident: AtIdentifier<'static>, rkey: SmolStr },
105 #[route("/sd/:rkey")]
106 PcktEntry { ident: AtIdentifier<'static>, rkey: SmolStr },
107 // External blog routes (NSID paths - replace at:// with https://host/)
108 #[route("/com.whtwnd.blog.entry/:rkey")]
109 WhiteWindEntryNsid { ident: AtIdentifier<'static>, rkey: SmolStr },
110 #[route("/pub.leaflet.document/:rkey")]
111 LeafletEntryNsid { ident: AtIdentifier<'static>, rkey: SmolStr },
112 #[route("/site.standard.document/:rkey")]
113 PcktEntryNsid { ident: AtIdentifier<'static>, rkey: SmolStr },
114 #[route("/blog.pckt.document/:rkey")]
115 PcktEntryBlogNsid { ident: AtIdentifier<'static>, rkey: SmolStr },
116 // Notebook routes
117 #[nest("/:book_title")]
118 #[layout(Notebook)]
119 #[route("/")]
120 NotebookIndex { ident: AtIdentifier<'static>, book_title: SmolStr },
121 // Settings must come before /:title to avoid capture
122 #[route("/settings")]
123 NotebookSettings { ident: AtIdentifier<'static>, book_title: SmolStr },
124 #[route("/:title")]
125 EntryPage { ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr },
126 // Entry by rkey (canonical path)
127 #[route("/e/:rkey")]
128 NotebookEntryByRkey { ident: AtIdentifier<'static>, book_title: SmolStr, rkey: SmolStr },
129 #[route("/e/:rkey/edit")]
130 NotebookEntryEdit { ident: AtIdentifier<'static>, book_title: SmolStr, rkey: SmolStr },
131}
132
133pub static CONFIG: LazyLock<Config> = LazyLock::new(|| Config {
134 oauth: OAuthConfig::from_env().as_metadata(),
135});
136
137const FAVICON: Asset = asset!("/assets/weaver_photo_sm.jpg");
138const MAIN_CSS: Asset = asset!("/assets/styling/main.css");
139const THEME_DEFAULTS_CSS: Asset = asset!("/assets/styling/theme-defaults.css");
140
141#[component]
142pub fn App() -> Element {
143 #[allow(unused)]
144 let fetcher = use_context_provider(|| {
145 fetch::Fetcher::new(OAuthClient::new(
146 AuthStore::new(),
147 ClientData::new_public(CONFIG.oauth.clone()),
148 ))
149 });
150
151 // Read host context from request extensions (set by middleware).
152 #[cfg(feature = "fullstack-server")]
153 let host_ctx = {
154 use_server_cached(|| {
155 use dioxus::fullstack::FullstackContext;
156
157 let ctx = FullstackContext::current();
158 ctx.and_then(|c| {
159 let parts = c.parts_mut();
160 parts.extensions.get::<HostContext>().cloned()
161 })
162 .unwrap_or(HostContext::MainDomain)
163 })
164 };
165
166 #[cfg(not(feature = "fullstack-server"))]
167 let host_ctx = HostContext::MainDomain;
168
169 let auth_state = use_signal(|| AuthState::default());
170 #[allow(unused)]
171 let auth_state = use_context_provider(|| auth_state);
172
173 // Provide link mode for router-agnostic link generation.
174 let _link_mode = use_context_provider(|| host_ctx.link_mode());
175
176 #[cfg(all(target_family = "wasm", target_os = "unknown"))]
177 let restore_result = {
178 let fetcher = fetcher.clone();
179 use_resource(move || {
180 let fetcher = fetcher.clone();
181 async move { auth::restore_session(fetcher, auth_state).await }
182 })
183 };
184 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
185 let restore_result: Option<auth::RestoreResult> = None;
186
187 #[cfg(all(target_family = "wasm", target_os = "unknown",))]
188 {
189 use_effect(move || {
190 let fetcher = fetcher.clone();
191 spawn(async move {
192 use crate::service_worker;
193
194 tracing::info!("Registering service worker");
195 let _ = service_worker::register_service_worker().await;
196 });
197 });
198 }
199
200 use_context_provider(|| restore_result);
201
202 // Dispatch to appropriate app based on host context.
203 match host_ctx {
204 HostContext::Subdomain(ctx) => {
205 tracing::info!("App: rendering SubdomainApp");
206 use_context_provider(|| ctx);
207 rsx! { SubdomainApp {} }
208 }
209 HostContext::CustomDomain(ctx) => {
210 tracing::info!("App: rendering CustomDomainApp");
211 use_context_provider(|| ctx);
212 rsx! { CustomDomainApp {} }
213 }
214 HostContext::MainDomain => {
215 tracing::info!("App: rendering MainDomainApp");
216 rsx! { MainDomainApp {} }
217 }
218 }
219}
220
221#[component]
222pub fn MainDomainApp() -> Element {
223 rsx! {
224 document::Link { rel: "icon", href: FAVICON }
225 // Preconnect for external fonts (before loading them)
226 document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
227 document::Link { rel: "preconnect", href: "https://fonts.gstatic.com" }
228 // Theme defaults first: CSS variables, font-faces, reset
229 document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS }
230 document::Link { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=IBM+Plex+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap" }
231 // App shell styles (depends on theme variables)
232 document::Link { rel: "stylesheet", href: MAIN_CSS }
233 components::toast::ToastProvider {
234 Router::<Route> {}
235 }
236 }
237}
238
239// And then our Outlet is wrapped in a fallback UI
240#[component]
241pub fn ErrorLayout() -> Element {
242 rsx! {
243 ErrorBoundary {
244 handle_error: move |_err: ErrorContext| {
245 #[cfg(feature = "fullstack-server")]
246 {
247 let http_error = FullstackContext::commit_error_status(_err.error().unwrap());
248 match http_error.status {
249 StatusCode::NOT_FOUND => rsx! { div { "404 - Page not found" } },
250 _ => rsx! { div { "An unknown error occurred" } },
251 }
252 }
253 #[cfg(not(feature = "fullstack-server"))]
254 {
255 rsx! { div { "An error occurred" } }
256 }
257 },
258 Outlet::<Route> {}
259 }
260 }
261}
262
263#[cfg(all(feature = "fullstack-server", feature = "server"))]
264pub async fn favicon() -> axum::response::Response {
265 use axum::{http::header::CONTENT_TYPE, response::IntoResponse};
266 let favicon_bytes = include_bytes!("../assets/weaver_photo_sm.jpg");
267
268 ([(CONTENT_TYPE, "image/jpg")], favicon_bytes).into_response()
269}