reinhardt_admin/pages/components/
layout.rs1use reinhardt_pages::component::Page;
10use reinhardt_pages::page;
11
12#[derive(Debug, Clone)]
14pub struct ModelInfo {
15 pub name: String,
17 pub url: String,
19}
20
21pub fn header(site_name: &str, user_name: Option<&str>) -> Page {
33 let site_name = site_name.to_string();
34 let user_display = user_name.unwrap_or("Guest").to_string();
35
36 page!(|site_name: String, user_display: String| {
37 nav {
38 class: "flex items-center justify-between px-6 py-3 bg-slate-900 text-white animate__animated animate__fadeInDown",
39 style: "position: fixed; top: 0; left: 0; right: 0; z-index: 50; height: 56px;",
40 div {
41 class: "flex items-center gap-3",
42 a {
43 class: "font-display text-lg font-bold tracking-tight text-white no-underline hover:text-amber-400",
44 href: "/admin/",
45 { site_name }
46 }
47 }
48 div {
49 class: "flex items-center gap-2 text-sm text-slate-400",
50 span { { format!("User: {}", user_display) } }
51 }
52 }
53 })(site_name, user_display)
54}
55
56fn is_active_path(model_url: &str, current_path: Option<&str>) -> bool {
62 current_path.is_some_and(|path| {
63 let normalized_url = model_url.trim_end_matches('/');
64 path == model_url
65 || path == normalized_url
66 || path.starts_with(&format!("{}/", normalized_url))
67 })
68}
69
70pub fn sidebar(models: &[ModelInfo], current_path: Option<&str>) -> Page {
87 use reinhardt_pages::component::Component;
88 use reinhardt_pages::router::Link;
89
90 let nav_items: Vec<Page> = models
91 .iter()
92 .map(|model| {
93 let is_active = is_active_path(&model.url, current_path);
94 let item_class = if is_active {
95 "block px-4 py-2.5 text-sm no-underline border-l-3 border-transparent admin-nav-active"
96 } else {
97 "block px-4 py-2.5 text-sm text-slate-400 no-underline border-l-3 border-transparent hover:text-white hover:bg-slate-800"
98 };
99
100 let link = Link::new(model.url.clone(), model.name.clone())
101 .class(item_class)
102 .render();
103
104 page!(|link: Page| {
105 li {
106 class: "list-none",
107 { link }
108 }
109 })(link)
110 })
111 .collect();
112
113 page!(|nav_items: Vec<Page>| {
114 div {
115 class: "admin-sidebar bg-slate-900 border-r border-slate-800 animate__animated animate__fadeInLeft",
116 style: "width: 240px; height: 100vh; position: fixed; top: 56px; left: 0; overflow-y: auto; padding-top: 1rem;",
117 div {
118 class: "px-4 pb-3 mb-2 border-b border-slate-800",
119 span {
120 class: "text-xs font-semibold uppercase tracking-wider text-slate-500",
121 "Models"
122 }
123 }
124 ul {
125 class: "flex flex-col gap-0.5 px-0 m-0",
126 { nav_items }
127 }
128 }
129 })(nav_items)
130}
131
132pub fn footer(version: &str) -> Page {
144 let version = version.to_string();
145
146 page!(|version: String| {
147 footer {
148 class: "text-center py-4 text-xs text-slate-400 border-t border-slate-200 animate__animated animate__fadeIn",
149 style: "margin-left: 240px;",
150 { format!("Reinhardt Admin v{}", version) }
151 }
152 })(version)
153}
154
155pub fn main_layout(
174 site_name: &str,
175 models: &[ModelInfo],
176 user_name: Option<&str>,
177 version: &str,
178 router: std::sync::Arc<reinhardt_urls::routers::ClientRouter>,
179) -> Page {
180 let current_path = router.current_path().get();
183 let header_page = header(site_name, user_name);
184 let sidebar_page = sidebar(models, Some(¤t_path));
185 let footer_page = footer(version);
186 let outlet = router.render_current();
187
188 page!(|header_page: Page, sidebar_page: Page, outlet: Page, footer_page: Page| {
189 div {
190 class: "admin-layout min-h-screen bg-slate-50",
191 { header_page }
192 { sidebar_page }
193 main {
194 class: "bg-slate-50",
195 style: "margin-left: 240px; margin-top: 56px; padding: 1.5rem 2rem; min-height: calc(100vh - 120px);",
196 { outlet }
197 }
198 { footer_page }
199 }
200 })(header_page, sidebar_page, outlet, footer_page)
201}
202
203#[cfg(all(test, server))]
204mod tests {
205 use rstest::rstest;
206
207 use super::is_active_path;
208
209 #[rstest]
212 fn test_exact_match_with_trailing_slash() {
213 let model_url = "/admin/users/";
215 let current_path = Some("/admin/users/");
216
217 let result = is_active_path(model_url, current_path);
219
220 assert!(result);
222 }
223
224 #[rstest]
225 fn test_match_without_trailing_slash() {
226 let model_url = "/admin/users/";
228 let current_path = Some("/admin/users");
229
230 let result = is_active_path(model_url, current_path);
232
233 assert!(result);
235 }
236
237 #[rstest]
238 fn test_sub_page_matches() {
239 let model_url = "/admin/users/";
241 let current_path = Some("/admin/users/42/change/");
242
243 let result = is_active_path(model_url, current_path);
245
246 assert!(result);
248 }
249
250 #[rstest]
251 fn test_similar_prefix_does_not_match() {
252 let model_url = "/admin/users/";
254 let current_path = Some("/admin/usergroups/");
255
256 let result = is_active_path(model_url, current_path);
258
259 assert!(!result);
261 }
262
263 #[rstest]
264 fn test_root_admin_path_matches() {
265 let model_url = "/admin/";
267 let current_path = Some("/admin/");
268
269 let result = is_active_path(model_url, current_path);
271
272 assert!(result);
274 }
275
276 #[rstest]
277 fn test_none_current_path_does_not_match() {
278 let model_url = "/admin/users/";
280 let current_path = None;
281
282 let result = is_active_path(model_url, current_path);
284
285 assert!(!result);
287 }
288
289 #[rstest]
290 fn test_different_path_does_not_match() {
291 let model_url = "/admin/users/";
293 let current_path = Some("/admin/posts/");
294
295 let result = is_active_path(model_url, current_path);
297
298 assert!(!result);
300 }
301}