1use leptos::children::{ChildrenFn, ViewFn};
11use leptos::error::Errors;
12use leptos::prelude::*;
13use leptos_router::components::Outlet;
14use leptos_router::hooks::use_location;
15
16use crate::api::error::ApiError;
17use crate::components::footer::Footer;
18use crate::components::header::{Header, SearchIconLink};
19use crate::components::nav::build_nav_items;
20use crate::components::palette::use_search_palette_provider;
21use crate::components::search_palette::SearchPalette;
22use crate::components::share_meta::SiteShareMeta;
23use crate::components::skeletons::archive_skeleton::{ArchiveSkeleton, TagDetailSkeleton};
24use crate::components::skeletons::changelog_skeleton::ChangelogSkeleton;
25use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
26use crate::components::skeletons::friends_skeleton::FriendsSkeleton;
27use crate::components::skeletons::home_skeleton::HomeSkeleton;
28use crate::components::skeletons::notes_route_skeleton::NotesRouteSkeleton;
29use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
30use crate::components::skeletons::search_skeleton::SearchSkeleton;
31use crate::components::skeletons::writing_guide_skeleton::WritingGuideSkeleton;
32use crate::navigation::use_rendered_route;
33use crate::pages::not_found::{NotFound, PageLoadFailed};
34use crate::route_prefetch::{match_route, RouteTarget};
35use crate::routes::{is_post_path, is_showcase_path};
36use crate::theme::ThemeToggle;
37use crate::utils::error::is_not_found;
38
39#[derive(Debug, Clone, PartialEq)]
43enum SkeletonKind {
44 Notes,
46 HomePage(i32),
48 Archives,
50 TagDetail(String),
52 Friends,
54 Search,
56 PostDetail,
58 Changelog,
60 WritingGuide,
62 Showcase,
64 Fallback,
66}
67
68fn skeleton_kind(path: &str) -> SkeletonKind {
70 let path = match path.trim_end_matches('/') {
71 "" => "/",
72 trimmed => trimmed,
73 };
74 if path == "/notes" || path.starts_with("/notes/") {
75 return SkeletonKind::Notes;
76 }
77 if let Some(page) = path
78 .strip_prefix("/page/")
79 .and_then(|page| page.parse::<i32>().ok())
80 {
81 return SkeletonKind::HomePage(page.max(1));
82 }
83 if let Some(tag) = path.strip_prefix("/tags/").filter(|tag| !tag.contains('/')) {
84 return SkeletonKind::TagDetail(
85 percent_encoding::percent_decode_str(tag)
86 .decode_utf8_lossy()
87 .into_owned(),
88 );
89 }
90 if is_post_path(path) {
91 return SkeletonKind::PostDetail;
92 }
93 if is_showcase_path(path) {
94 return SkeletonKind::Showcase;
95 }
96 match path {
97 "/archives" => SkeletonKind::Archives,
98 "/friends" => SkeletonKind::Friends,
99 "/search" => SkeletonKind::Search,
100 "/changelog" => SkeletonKind::Changelog,
101 "/about/writing" => SkeletonKind::WritingGuide,
102 _ => SkeletonKind::Fallback,
103 }
104}
105
106fn skeleton_view(path: &str) -> AnyView {
108 match skeleton_kind(path) {
109 SkeletonKind::Notes => view! { <NotesRouteSkeleton path=path.to_string()/> }.into_any(),
110 SkeletonKind::HomePage(page) => view! { <HomeSkeleton current_page=page/> }.into_any(),
111 SkeletonKind::Archives => view! { <ArchiveSkeleton/> }.into_any(),
112 SkeletonKind::TagDetail(tag) => view! { <TagDetailSkeleton tag=tag/> }.into_any(),
113 SkeletonKind::Friends => view! {
114 <div class="friends">
115 <FriendsSkeleton/>
116 </div>
117 }
118 .into_any(),
119 SkeletonKind::Search => view! { <SearchSkeleton/> }.into_any(),
120 SkeletonKind::PostDetail => view! { <PostDetailSkeleton/> }.into_any(),
121 SkeletonKind::Changelog => view! { <ChangelogSkeleton/> }.into_any(),
122 SkeletonKind::WritingGuide => view! { <WritingGuideSkeleton/> }.into_any(),
123 SkeletonKind::Showcase => {
124 view! { <div class="py-20" role="status">"正在翻开组件图鉴…"</div> }.into_any()
125 }
126 SkeletonKind::Fallback => view! { <HomeSkeleton/> }.into_any(),
127 }
128}
129
130fn route_skeleton(path: &str) -> AnyView {
132 if matches!(
133 skeleton_kind(path),
134 SkeletonKind::TagDetail(_) | SkeletonKind::Showcase
135 ) {
136 return skeleton_view(path);
137 }
138 let path = path.to_string();
139 view! { <DelayedSkeleton>{skeleton_view(&path)}</DelayedSkeleton> }.into_any()
140}
141
142fn pending_target(target: &str, rendered: &str) -> Option<String> {
147 if target == rendered {
148 return None;
149 }
150 match match_route(target)? {
151 RouteTarget::Login | RouteTarget::Register | RouteTarget::Admin(_) => None,
152 _ => Some(target.to_string()),
153 }
154}
155
156fn main_width_class(path: &str) -> &'static str {
158 if is_post_path(path) {
159 "max-w-[1240px]"
160 } else if is_showcase_path(path) {
161 "max-w-6xl"
162 } else {
163 "max-w-[880px]"
164 }
165}
166
167fn route_error_fallback(errors: ArcRwSignal<Errors>) -> impl IntoView {
169 move || {
170 let missing = errors.with(|errors| {
171 errors
172 .iter()
173 .any(|(_, error)| error.downcast_ref::<ApiError>().is_some_and(is_not_found))
174 });
175 if missing {
176 view! { <NotFound/> }.into_any()
177 } else {
178 view! { <PageLoadFailed/> }.into_any()
179 }
180 }
181}
182
183#[component]
193pub fn FrontendLayout(#[prop(optional)] children: Option<ChildrenFn>) -> impl IntoView {
194 let target_path = use_location().pathname;
196 let route = use_rendered_route();
199 let pathname = Memo::new(move |_| route.read().pathname.clone());
200 let nav_items = Memo::new(move |_| build_nav_items(&pathname.read()));
201 let palette = use_search_palette_provider();
202 let max_width = "max-w-[880px]";
205 let is_post = Memo::new(move |_| is_post_path(&pathname.read()));
207 let main_width = Signal::derive(move || pathname.with(|path| main_width_class(path)));
208 let share_path = Memo::new(move |_| route.read().key());
210 let pending = Memo::new(move |_| pending_target(&target_path.read(), &pathname.read()));
212
213 view! {
214 <FrontendShell
215 max_width=main_width
216 header=move || {
217 view! {
218 <Header
219 max_width=max_width
220 nav_items=nav_items
221 right_content=move || {
222 view! {
223 <SearchIconLink on_click=move |_: leptos::ev::MouseEvent| {
224 palette.open()
225 }/>
226 <ThemeToggle icon_only=true/>
227 }
228 }
229 />
230 }
231 }
232 footer=move || view! { <Footer max_width=max_width/> }
233 main_content=move || {
234 let children = children.clone();
235 view! {
236 <Show when=move || pending.read().is_some()>
238 <div
239 class=move || {
240 format!(
241 "route-pending site-route-pending {}",
242 pending.read().as_deref().map_or("", main_width_class),
243 )
244 }
245 aria-hidden="true"
246 >
247 {move || pending.get().map(|path| skeleton_view(&path))}
248 </div>
249 </Show>
250 <Show when=move || !is_post.get()>
251 <SiteShareMeta path=share_path/>
252 </Show>
253 <Suspense fallback=move || route_skeleton(&target_path.get_untracked())>
257 <ErrorBoundary fallback=route_error_fallback>
258 {match children {
259 Some(children) => children().into_any(),
260 None => view! { <Outlet/> }.into_any(),
261 }}
262 </ErrorBoundary>
263 </Suspense>
264 }
265 }
266 />
267 <SearchPalette/>
270 }
271}
272
273#[component]
280pub(crate) fn FrontendShell(
281 #[prop(into)] header: ViewFn,
282 #[prop(into)] main_content: ViewFn,
283 #[prop(into)] footer: ViewFn,
284 #[prop(into, default = "max-w-[880px]".into())] max_width: Signal<&'static str>,
285 #[prop(default = "min-h-screen")] min_height: &'static str,
286) -> impl IntoView {
287 view! {
288 <div class=format!("site-shell {min_height} flex flex-col bg-surface-0")>
289 {header.run()}
290 <main class=move || {
291 format!(
292 "flex-1 w-full {} mx-auto px-6 py-6 md:py-12 overflow-x-clip",
293 max_width.get(),
294 )
295 }>{main_content.run()}</main>
296 {footer.run()}
297 </div>
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn error_boundary_does_not_turn_message_text_into_a_404() {
307 for (error, missing) in [
308 (ApiError::NotFound("文章不存在".into()), true),
309 (ApiError::BadRequest("not_found:业务拒绝".into()), false),
310 (ApiError::Internal("not_found".into()), false),
311 ] {
312 let html = Owner::new().with(|| {
313 provide_context(leptos_router::location::RequestUrl::new("/"));
314 view! {
315 <leptos_router::components::Router>
316 <ErrorBoundary fallback=route_error_fallback>
317 {Err::<(), _>(error)}
318 </ErrorBoundary>
319 </leptos_router::components::Router>
320 }
321 .to_html()
322 });
323 assert_eq!(html.contains("这个页面不存在"), missing);
324 assert_eq!(html.contains("页面加载失败"), !missing);
325 }
326 }
327
328 #[test]
329 fn skeleton_kind_matches_base_route_mapping() {
330 assert_eq!(skeleton_kind("/notes"), SkeletonKind::Notes);
331 assert_eq!(skeleton_kind("/notes/book/3"), SkeletonKind::Notes);
332 assert_eq!(skeleton_kind("/notes/some-note"), SkeletonKind::Notes);
333 assert_eq!(skeleton_kind("/page/3"), SkeletonKind::HomePage(3));
334 assert_eq!(skeleton_kind("/page/0"), SkeletonKind::HomePage(1));
335 assert_eq!(skeleton_kind("/page/-4"), SkeletonKind::HomePage(1));
336 assert_eq!(skeleton_kind("/archives"), SkeletonKind::Archives);
337 assert_eq!(skeleton_kind("/archives/"), SkeletonKind::Archives);
338 assert_eq!(skeleton_kind("/friends"), SkeletonKind::Friends);
339 assert_eq!(skeleton_kind("/search"), SkeletonKind::Search);
340 assert_eq!(skeleton_kind("/post/hello"), SkeletonKind::PostDetail);
341 assert_eq!(skeleton_kind("/changelog"), SkeletonKind::Changelog);
342 assert_eq!(skeleton_kind("/about/writing"), SkeletonKind::WritingGuide);
343 assert_eq!(skeleton_kind("/about/components"), SkeletonKind::Showcase);
344 assert_eq!(
345 skeleton_kind("/about/components/button"),
346 SkeletonKind::Showcase
347 );
348 }
349
350 #[test]
351 fn skeleton_kind_decodes_tag_name() {
352 assert_eq!(
353 skeleton_kind("/tags/Rust%20%E7%BC%96%E7%A8%8B"),
354 SkeletonKind::TagDetail("Rust 编程".to_string())
355 );
356 assert_eq!(skeleton_kind("/tags"), SkeletonKind::Fallback);
358 }
359
360 #[test]
361 fn skeleton_kind_falls_back_to_home() {
362 assert_eq!(skeleton_kind("/"), SkeletonKind::Fallback);
363 assert_eq!(skeleton_kind("/about"), SkeletonKind::Fallback);
364 assert_eq!(skeleton_kind("/page/abc"), SkeletonKind::Fallback);
365 assert_eq!(skeleton_kind("/no-such-page"), SkeletonKind::Fallback);
366 }
367
368 #[test]
369 fn main_width_follows_page_kind() {
370 assert_eq!(main_width_class("/post/hello"), "max-w-[1240px]");
371 assert_eq!(main_width_class("/about/components/button"), "max-w-6xl");
372 assert_eq!(main_width_class("/"), "max-w-[880px]");
373 assert_eq!(main_width_class("/archives"), "max-w-[880px]");
374 }
375
376 #[test]
377 fn pending_target_only_covers_frontend_routes() {
378 assert_eq!(pending_target("/archives", "/archives"), None);
379 assert_eq!(
380 pending_target("/archives", "/"),
381 Some("/archives".to_string())
382 );
383 assert_eq!(
384 pending_target("/post/hello", "/"),
385 Some("/post/hello".to_string())
386 );
387 assert_eq!(pending_target("/admin", "/"), None);
388 assert_eq!(pending_target("/admin/logs", "/"), None);
389 assert_eq!(pending_target("/login", "/"), None);
390 assert_eq!(pending_target("/register", "/"), None);
391 assert_eq!(pending_target("/no/such/page", "/"), None);
392 }
393
394 #[test]
395 fn shell_renders_regions_in_order_with_width_class() {
396 let html = Owner::new().with(|| {
397 view! {
398 <FrontendShell
399 header=|| view! { <header id="h"></header> }
400 main_content=|| view! { <p id="m"></p> }
401 footer=|| view! { <footer id="f"></footer> }
402 max_width="max-w-6xl"
403 min_height="min-h-0"
404 />
405 }
406 .to_html()
407 });
408 assert!(html.contains(
409 "class=\"flex-1 w-full max-w-6xl mx-auto px-6 py-6 md:py-12 overflow-x-clip\""
410 ));
411 let h = html.find("id=\"h\"").unwrap();
412 let main = html.find("<main").unwrap();
413 let m = html.find("id=\"m\"").unwrap();
414 let f = html.find("id=\"f\"").unwrap();
415 assert!(h < main && main < m && m < f);
416 }
417
418 #[test]
419 fn shell_defaults_to_index_width() {
420 let html = Owner::new().with(|| {
421 view! {
422 <FrontendShell
423 header=|| ()
424 main_content=|| ()
425 footer=|| ()
426 />
427 }
428 .to_html()
429 });
430 assert!(html.contains("flex-1 w-full max-w-[880px] mx-auto"));
431 }
432}