Skip to main content

reinhardt_admin/pages/components/
layout.rs

1//! Layout components
2//!
3//! Provides layout components for the admin panel:
4//! - `Header` - Top navigation bar
5//! - `Sidebar` - Side navigation menu
6//! - `Footer` - Footer component
7//! - `MainLayout` - Main layout wrapper
8
9use reinhardt_pages::component::Page;
10use reinhardt_pages::page;
11
12/// Model information for navigation
13#[derive(Debug, Clone)]
14pub struct ModelInfo {
15	/// Model name (display name)
16	pub name: String,
17	/// URL path for the model list view
18	pub url: String,
19}
20
21/// Header component
22///
23/// Displays the top navigation bar with site title and user menu.
24///
25/// # Example
26///
27/// ```ignore
28/// use reinhardt_admin::pages::components::layout::header;
29///
30/// header("My Admin Panel", Some("john_doe"))
31/// ```
32pub 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
56/// Determines whether a nav item URL matches the current path.
57///
58/// Returns `true` when `current_path` equals the model URL exactly,
59/// equals it without a trailing slash, or starts with the model URL
60/// segment (to match sub-pages while avoiding similar-prefix collisions).
61fn 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
70/// Sidebar component
71///
72/// Displays the side navigation menu with model links.
73/// Uses Link component for SPA navigation.
74///
75/// # Example
76///
77/// ```ignore
78/// use reinhardt_admin::pages::components::layout::{sidebar, ModelInfo};
79///
80/// let models = vec![
81///     ModelInfo { name: "Users".to_string(), url: "/admin/users/".to_string() },
82///     ModelInfo { name: "Posts".to_string(), url: "/admin/posts/".to_string() },
83/// ];
84/// sidebar(&models, Some("/admin/users/"))
85/// ```
86pub 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
132/// Footer component
133///
134/// Displays the footer with copyright and version information.
135///
136/// # Example
137///
138/// ```ignore
139/// use reinhardt_admin::pages::components::layout::footer;
140///
141/// footer("0.1.0")
142/// ```
143pub 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
155/// Main layout wrapper
156///
157/// Wraps the main content area with header, sidebar, and footer.
158/// Uses RouterOutlet for dynamic content rendering.
159///
160/// # Example
161///
162/// ```ignore
163/// use reinhardt_admin::pages::components::layout::{main_layout, ModelInfo};
164/// use reinhardt_urls::routers::ClientRouter;
165/// use std::sync::Arc;
166///
167/// let models = vec![
168///     ModelInfo { name: "Users".to_string(), url: "/admin/users/".to_string() },
169/// ];
170/// let router = Arc::new(ClientRouter::new());
171/// main_layout("My Admin", &models, None, "0.1.0", router)
172/// ```
173pub 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	// RouterOutlet removed; using ClientRouter::render_current() instead
181
182	let current_path = router.current_path().get();
183	let header_page = header(site_name, user_name);
184	let sidebar_page = sidebar(models, Some(&current_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	// ==================== is_active_path tests ====================
210
211	#[rstest]
212	fn test_exact_match_with_trailing_slash() {
213		// Arrange
214		let model_url = "/admin/users/";
215		let current_path = Some("/admin/users/");
216
217		// Act
218		let result = is_active_path(model_url, current_path);
219
220		// Assert
221		assert!(result);
222	}
223
224	#[rstest]
225	fn test_match_without_trailing_slash() {
226		// Arrange
227		let model_url = "/admin/users/";
228		let current_path = Some("/admin/users");
229
230		// Act
231		let result = is_active_path(model_url, current_path);
232
233		// Assert
234		assert!(result);
235	}
236
237	#[rstest]
238	fn test_sub_page_matches() {
239		// Arrange
240		let model_url = "/admin/users/";
241		let current_path = Some("/admin/users/42/change/");
242
243		// Act
244		let result = is_active_path(model_url, current_path);
245
246		// Assert
247		assert!(result);
248	}
249
250	#[rstest]
251	fn test_similar_prefix_does_not_match() {
252		// Arrange
253		let model_url = "/admin/users/";
254		let current_path = Some("/admin/usergroups/");
255
256		// Act
257		let result = is_active_path(model_url, current_path);
258
259		// Assert
260		assert!(!result);
261	}
262
263	#[rstest]
264	fn test_root_admin_path_matches() {
265		// Arrange
266		let model_url = "/admin/";
267		let current_path = Some("/admin/");
268
269		// Act
270		let result = is_active_path(model_url, current_path);
271
272		// Assert
273		assert!(result);
274	}
275
276	#[rstest]
277	fn test_none_current_path_does_not_match() {
278		// Arrange
279		let model_url = "/admin/users/";
280		let current_path = None;
281
282		// Act
283		let result = is_active_path(model_url, current_path);
284
285		// Assert
286		assert!(!result);
287	}
288
289	#[rstest]
290	fn test_different_path_does_not_match() {
291		// Arrange
292		let model_url = "/admin/users/";
293		let current_path = Some("/admin/posts/");
294
295		// Act
296		let result = is_active_path(model_url, current_path);
297
298		// Assert
299		assert!(!result);
300	}
301}