atproto blogging
1//! Router-agnostic link and navigation for shared components.
2//!
3//! AppLink dispatches to either `Link<Route>` or `Link<SubdomainRoute>` based on
4//! the current LinkMode context, preserving proper client-side navigation semantics.
5//!
6//! AppNavigate provides programmatic navigation that dispatches similarly.
7
8use crate::env::WEAVER_APP_HOST;
9use crate::host_mode::LinkMode;
10use crate::{CustomDomainRoute, Route, SubdomainRoute};
11use dioxus::prelude::*;
12use jacquard::smol_str::SmolStr;
13use jacquard::types::string::AtIdentifier;
14
15/// Target for router-agnostic links.
16#[derive(Clone, PartialEq)]
17pub enum AppLinkTarget {
18 /// Entry by title path: /:ident/:book/:title or /:title
19 Entry {
20 ident: AtIdentifier<'static>,
21 book_title: SmolStr,
22 entry_path: SmolStr,
23 },
24 /// Entry by rkey: /:ident/:book/e/:rkey or /e/:rkey
25 EntryByRkey {
26 ident: AtIdentifier<'static>,
27 book_title: SmolStr,
28 rkey: SmolStr,
29 },
30 /// Entry edit: /:ident/:book/e/:rkey/edit or /e/:rkey/edit
31 EntryEdit {
32 ident: AtIdentifier<'static>,
33 book_title: SmolStr,
34 rkey: SmolStr,
35 },
36 /// Notebook index: /:ident/:book or /
37 Notebook {
38 ident: AtIdentifier<'static>,
39 book_title: SmolStr,
40 },
41 /// Profile/repository: /:ident or /u/:ident
42 Profile { ident: AtIdentifier<'static> },
43 /// Standalone entry: /:ident/e/:rkey (always main domain in subdomain mode)
44 StandaloneEntry {
45 ident: AtIdentifier<'static>,
46 rkey: SmolStr,
47 },
48 /// Standalone entry edit: /:ident/e/:rkey/edit
49 StandaloneEntryEdit {
50 ident: AtIdentifier<'static>,
51 rkey: SmolStr,
52 },
53 /// New draft: /:ident/new?notebook=...
54 NewDraft {
55 ident: AtIdentifier<'static>,
56 notebook: Option<SmolStr>,
57 },
58 /// Drafts list: /:ident/drafts
59 Drafts { ident: AtIdentifier<'static> },
60 /// Invites page: /:ident/invites
61 Invites { ident: AtIdentifier<'static> },
62}
63
64#[derive(Props, Clone, PartialEq)]
65pub struct AppLinkProps {
66 pub to: AppLinkTarget,
67 #[props(default)]
68 pub class: Option<String>,
69 pub children: Element,
70}
71
72/// Router-agnostic link component.
73///
74/// Renders the appropriate `Link<Route>` or `Link<SubdomainRoute>` based on LinkMode context.
75#[component]
76pub fn AppLink(props: AppLinkProps) -> Element {
77 let link_mode = use_context::<LinkMode>();
78 let class = props.class.clone().unwrap_or_default();
79
80 match link_mode {
81 LinkMode::MainDomain => {
82 let route = match props.to.clone() {
83 AppLinkTarget::Entry {
84 ident,
85 book_title,
86 entry_path,
87 } => Route::EntryPage {
88 ident,
89 book_title,
90 title: entry_path,
91 },
92 AppLinkTarget::EntryByRkey {
93 ident,
94 book_title,
95 rkey,
96 } => Route::NotebookEntryByRkey {
97 ident,
98 book_title,
99 rkey,
100 },
101 AppLinkTarget::EntryEdit {
102 ident,
103 book_title,
104 rkey,
105 } => Route::NotebookEntryEdit {
106 ident,
107 book_title,
108 rkey,
109 },
110 AppLinkTarget::Notebook { ident, book_title } => {
111 Route::NotebookIndex { ident, book_title }
112 }
113 AppLinkTarget::Profile { ident } => Route::RepositoryIndex { ident },
114 AppLinkTarget::StandaloneEntry { ident, rkey } => {
115 Route::StandaloneEntry { ident, rkey }
116 }
117 AppLinkTarget::StandaloneEntryEdit { ident, rkey } => {
118 Route::StandaloneEntryEdit { ident, rkey }
119 }
120 AppLinkTarget::NewDraft { ident, notebook } => Route::NewDraft { ident, notebook },
121 AppLinkTarget::Drafts { ident } => Route::DraftsList { ident },
122 AppLinkTarget::Invites { ident } => Route::InvitesPage { ident },
123 };
124 rsx! {
125 Link { to: route, class: "{class}", {props.children} }
126 }
127 }
128 LinkMode::Subdomain => {
129 // For subdomain mode, some links go to SubdomainRoute, others to main domain
130 match props.to.clone() {
131 AppLinkTarget::Entry { entry_path, .. } => {
132 let route = SubdomainRoute::SubdomainEntry { title: entry_path };
133 rsx! {
134 Link { to: route, class: "{class}", {props.children} }
135 }
136 }
137 AppLinkTarget::EntryByRkey { rkey, .. } => {
138 let route = SubdomainRoute::SubdomainEntryByRkey { rkey };
139 rsx! {
140 Link { to: route, class: "{class}", {props.children} }
141 }
142 }
143 AppLinkTarget::EntryEdit { rkey, .. } => {
144 let route = SubdomainRoute::SubdomainEntryEdit { rkey };
145 rsx! {
146 Link { to: route, class: "{class}", {props.children} }
147 }
148 }
149 AppLinkTarget::Notebook { .. } => {
150 let route = SubdomainRoute::SubdomainLanding {};
151 rsx! {
152 Link { to: route, class: "{class}", {props.children} }
153 }
154 }
155 AppLinkTarget::Profile { ident } => {
156 let route = SubdomainRoute::SubdomainProfile { ident };
157 rsx! {
158 Link { to: route, class: "{class}", {props.children} }
159 }
160 }
161 // These go to main domain in subdomain mode
162 AppLinkTarget::StandaloneEntry { ident, rkey } => {
163 let href = format!("{}/{}/e/{}", WEAVER_APP_HOST, ident, rkey);
164 rsx! {
165 a { href: "{href}", class: "{class}", {props.children} }
166 }
167 }
168 AppLinkTarget::StandaloneEntryEdit { ident, rkey } => {
169 let href = format!("{}/{}/e/{}/edit", WEAVER_APP_HOST, ident, rkey);
170 rsx! {
171 a { href: "{href}", class: "{class}", {props.children} }
172 }
173 }
174 AppLinkTarget::NewDraft { ident, notebook } => {
175 let href = match notebook {
176 Some(nb) => format!("{}/{}/new?notebook={}", WEAVER_APP_HOST, ident, nb),
177 None => format!("{}/{}/new", WEAVER_APP_HOST, ident),
178 };
179 rsx! {
180 a { href: "{href}", class: "{class}", {props.children} }
181 }
182 }
183 AppLinkTarget::Drafts { ident } => {
184 let href = format!("{}/{}/drafts", WEAVER_APP_HOST, ident);
185 rsx! {
186 a { href: "{href}", class: "{class}", {props.children} }
187 }
188 }
189 AppLinkTarget::Invites { ident } => {
190 let href = format!("{}/{}/invites", WEAVER_APP_HOST, ident);
191 rsx! {
192 a { href: "{href}", class: "{class}", {props.children} }
193 }
194 }
195 }
196 }
197 LinkMode::CustomDomain => {
198 // Custom domain mode - uses CustomDomainRoute for path-based routing
199 match props.to.clone() {
200 AppLinkTarget::Entry { entry_path, .. } => {
201 // Entry by title maps to path page
202 let route = CustomDomainRoute::PathPage {
203 segments: vec![entry_path.to_string()],
204 };
205 rsx! {
206 Link { to: route, class: "{class}", {props.children} }
207 }
208 }
209 AppLinkTarget::EntryByRkey { rkey, .. } => {
210 let route = CustomDomainRoute::EntryByRkey { rkey };
211 rsx! {
212 Link { to: route, class: "{class}", {props.children} }
213 }
214 }
215 AppLinkTarget::EntryEdit { rkey, .. } => {
216 let route = CustomDomainRoute::EntryEdit { rkey };
217 rsx! {
218 Link { to: route, class: "{class}", {props.children} }
219 }
220 }
221 AppLinkTarget::Notebook { .. } => {
222 let route = CustomDomainRoute::Root {};
223 rsx! {
224 Link { to: route, class: "{class}", {props.children} }
225 }
226 }
227 AppLinkTarget::Profile { ident } => {
228 let route = CustomDomainRoute::Profile { ident };
229 rsx! {
230 Link { to: route, class: "{class}", {props.children} }
231 }
232 }
233 // These go to main domain in custom domain mode
234 AppLinkTarget::StandaloneEntry { ident, rkey } => {
235 let href = format!("{}/{}/e/{}", WEAVER_APP_HOST, ident, rkey);
236 rsx! {
237 a { href: "{href}", class: "{class}", {props.children} }
238 }
239 }
240 AppLinkTarget::StandaloneEntryEdit { ident, rkey } => {
241 let href = format!("{}/{}/e/{}/edit", WEAVER_APP_HOST, ident, rkey);
242 rsx! {
243 a { href: "{href}", class: "{class}", {props.children} }
244 }
245 }
246 AppLinkTarget::NewDraft { ident, notebook } => {
247 let href = match notebook {
248 Some(nb) => format!("{}/{}/new?notebook={}", WEAVER_APP_HOST, ident, nb),
249 None => format!("{}/{}/new", WEAVER_APP_HOST, ident),
250 };
251 rsx! {
252 a { href: "{href}", class: "{class}", {props.children} }
253 }
254 }
255 AppLinkTarget::Drafts { ident } => {
256 let href = format!("{}/{}/drafts", WEAVER_APP_HOST, ident);
257 rsx! {
258 a { href: "{href}", class: "{class}", {props.children} }
259 }
260 }
261 AppLinkTarget::Invites { ident } => {
262 let href = format!("{}/{}/invites", WEAVER_APP_HOST, ident);
263 rsx! {
264 a { href: "{href}", class: "{class}", {props.children} }
265 }
266 }
267 }
268 }
269 }
270}
271
272/// Navigation function type for programmatic routing.
273pub type NavigateFn = std::rc::Rc<dyn Fn(AppLinkTarget)>;
274
275/// Hook to get the app-wide navigation function.
276/// Must be used with AppNavigatorProvider in context.
277pub fn use_app_navigate() -> NavigateFn {
278 use_context::<NavigateFn>()
279}
280
281/// Provides the main domain navigation function.
282/// Call this in App to set up navigation context.
283pub fn use_main_navigator_provider() {
284 let navigator = use_navigator();
285 use_context_provider(move || {
286 let navigator = navigator.clone();
287 std::rc::Rc::new(move |target: AppLinkTarget| {
288 let route = match target {
289 AppLinkTarget::Entry {
290 ident,
291 book_title,
292 entry_path,
293 } => Route::EntryPage {
294 ident,
295 book_title,
296 title: entry_path,
297 },
298 AppLinkTarget::EntryByRkey {
299 ident,
300 book_title,
301 rkey,
302 } => Route::NotebookEntryByRkey {
303 ident,
304 book_title,
305 rkey,
306 },
307 AppLinkTarget::EntryEdit {
308 ident,
309 book_title,
310 rkey,
311 } => Route::NotebookEntryEdit {
312 ident,
313 book_title,
314 rkey,
315 },
316 AppLinkTarget::Notebook { ident, book_title } => {
317 Route::NotebookIndex { ident, book_title }
318 }
319 AppLinkTarget::Profile { ident } => Route::RepositoryIndex { ident },
320 AppLinkTarget::StandaloneEntry { ident, rkey } => {
321 Route::StandaloneEntry { ident, rkey }
322 }
323 AppLinkTarget::StandaloneEntryEdit { ident, rkey } => {
324 Route::StandaloneEntryEdit { ident, rkey }
325 }
326 AppLinkTarget::NewDraft { ident, notebook } => Route::NewDraft { ident, notebook },
327 AppLinkTarget::Drafts { ident } => Route::DraftsList { ident },
328 AppLinkTarget::Invites { ident } => Route::InvitesPage { ident },
329 };
330 navigator.push(route);
331 }) as NavigateFn
332 });
333}
334
335/// Provides the subdomain navigation function.
336/// Call this in SubdomainApp to set up navigation context.
337pub fn use_subdomain_navigator_provider() {
338 let navigator = use_navigator();
339 use_context_provider(move || {
340 let navigator = navigator.clone();
341 std::rc::Rc::new(move |target: AppLinkTarget| {
342 match target {
343 // These navigate within subdomain
344 AppLinkTarget::Entry { entry_path, .. } => {
345 navigator.push(SubdomainRoute::SubdomainEntry { title: entry_path });
346 }
347 AppLinkTarget::EntryByRkey { rkey, .. } => {
348 navigator.push(SubdomainRoute::SubdomainEntryByRkey { rkey });
349 }
350 AppLinkTarget::EntryEdit { rkey, .. } => {
351 navigator.push(SubdomainRoute::SubdomainEntryEdit { rkey });
352 }
353 AppLinkTarget::Notebook { .. } => {
354 navigator.push(SubdomainRoute::SubdomainLanding {});
355 }
356 AppLinkTarget::Profile { ident } => {
357 navigator.push(SubdomainRoute::SubdomainProfile { ident });
358 }
359 // These go to main domain - use window.location
360 AppLinkTarget::StandaloneEntry { ident, rkey }
361 | AppLinkTarget::StandaloneEntryEdit { ident, rkey } => {
362 #[cfg(target_arch = "wasm32")]
363 if let Some(window) = web_sys::window() {
364 let path = format!("{}/{}/e/{}", WEAVER_APP_HOST, ident, rkey);
365 let _ = window.location().set_href(&path);
366 }
367 #[cfg(not(target_arch = "wasm32"))]
368 {
369 let _ = ident;
370 let _ = rkey;
371 }
372 }
373 AppLinkTarget::NewDraft { ident, notebook } => {
374 #[cfg(target_arch = "wasm32")]
375 if let Some(window) = web_sys::window() {
376 let path = match notebook {
377 Some(nb) => {
378 format!("{}/{}/new?notebook={}", WEAVER_APP_HOST, ident, nb)
379 }
380 None => format!("{}/{}/new", WEAVER_APP_HOST, ident),
381 };
382 let _ = window.location().set_href(&path);
383 }
384 #[cfg(not(target_arch = "wasm32"))]
385 {
386 let _ = notebook;
387 let _ = ident;
388 }
389 }
390 AppLinkTarget::Drafts { ident } => {
391 #[cfg(target_arch = "wasm32")]
392 if let Some(window) = web_sys::window() {
393 let path = format!("{}/{}/drafts", WEAVER_APP_HOST, ident);
394 let _ = window.location().set_href(&path);
395 }
396 #[cfg(not(target_arch = "wasm32"))]
397 let _ = ident;
398 }
399 AppLinkTarget::Invites { ident } => {
400 #[cfg(target_arch = "wasm32")]
401 if let Some(window) = web_sys::window() {
402 let path = format!("{}/{}/invites", WEAVER_APP_HOST, ident);
403 let _ = window.location().set_href(&path);
404 }
405 #[cfg(not(target_arch = "wasm32"))]
406 let _ = ident;
407 }
408 }
409 }) as NavigateFn
410 });
411}