Skip to main content

reinhardt_admin/pages/components/
common.rs

1//! Common reusable components
2//!
3//! Provides common UI components:
4//! - `Button` - Button component
5//! - `LoadingSpinner` - Loading indicator
6//! - `ErrorDisplay` - Error message display
7//! - `Pagination` - Pagination component
8//! - `SearchBar` - Search input component
9//!
10//! ## Design Note
11//!
12//! These components use the `page!` macro DSL for SSR compatibility and Router integration.
13//! Interactive components with event handlers will be hydrated on the client side.
14
15use std::sync::Arc;
16
17use reinhardt_pages::Signal;
18use reinhardt_pages::component::Page;
19use reinhardt_pages::page;
20
21/// Button variant styles
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ButtonVariant {
24	/// Primary action button (blue)
25	Primary,
26	/// Secondary action button (gray)
27	Secondary,
28	/// Success action button (green)
29	Success,
30	/// Danger action button (red)
31	Danger,
32	/// Warning action button (yellow/amber)
33	Warning,
34}
35
36impl ButtonVariant {
37	/// Get CSS class for this variant
38	pub fn class(&self) -> &'static str {
39		match self {
40			ButtonVariant::Primary => "admin-btn-primary",
41			ButtonVariant::Secondary => "admin-btn-secondary",
42			ButtonVariant::Success => "admin-btn-success",
43			ButtonVariant::Danger => "admin-btn-danger",
44			ButtonVariant::Warning => "admin-btn-warning",
45		}
46	}
47}
48
49/// Button component
50///
51/// Displays a styled button with various variants.
52/// When clicked, sets the provided Signal to true.
53///
54/// # Example
55///
56/// ```ignore
57/// use reinhardt_admin::pages::components::common::*;
58/// use reinhardt_pages::Signal;
59///
60/// let clicked = Signal::new(false);
61/// button("Click me", ButtonVariant::Primary, false, clicked)
62/// ```
63pub fn button(text: &str, variant: ButtonVariant, disabled: bool, on_click: Signal<bool>) -> Page {
64	let classes = format!("admin-btn {}", variant.class());
65	let text = text.to_string();
66
67	if disabled {
68		return page!(|classes: String, text: String| {
69			button {
70				class: classes,
71				type: "button",
72				disabled: true,
73				{ text }
74			}
75		})(classes, text);
76	}
77
78	page!(|classes: String, text: String, _on_click: Signal<bool>| {
79		button {
80			class: classes,
81			type: "button",
82			@click: move |_| {
83				_on_click.set(true);
84			},
85			{ text }
86		}
87	})(classes, text, on_click)
88}
89
90/// Loading spinner component
91///
92/// Displays a loading spinner while data is being fetched.
93///
94/// # Example
95///
96/// ```ignore
97/// use reinhardt_admin::pages::components::common::loading_spinner;
98///
99/// loading_spinner()
100/// ```
101pub fn loading_spinner() -> Page {
102	page!(|| {
103		div {
104			class: "flex justify-center items-center py-12",
105			div {
106				class: "admin-spinner",
107				role: "status",
108				span {
109					class: "sr-only",
110					"Loading..."
111				}
112			}
113		}
114	})()
115}
116
117/// Error display component
118///
119/// Displays error messages in a styled container.
120///
121/// # Example
122///
123/// ```ignore
124/// use reinhardt_admin::pages::components::common::error_display;
125///
126/// error_display("An error occurred", true)
127/// ```
128pub fn error_display(message: &str, dismissible: bool) -> Page {
129	let message = message.to_string();
130
131	if dismissible {
132		page!(|message: String| {
133			div {
134				class: "admin-alert admin-alert-danger flex items-start justify-between animate__animated animate__shakeX",
135				role: "alert",
136				span { { message } }
137				button {
138					class: "ml-4 text-red-400 hover:text-red-600 cursor-pointer",
139					type: "button",
140					aria_label: "Close",
141					"×"
142				}
143			}
144		})(message)
145	} else {
146		page!(|message: String| {
147			div {
148				class: "admin-alert admin-alert-danger animate__animated animate__shakeX",
149				role: "alert",
150				{ message }
151			}
152		})(message)
153	}
154}
155
156/// Pagination component
157///
158/// Displays pagination controls for navigating through pages.
159/// Updates the provided Signal when page navigation occurs.
160///
161/// # Example
162///
163/// ```ignore
164/// use reinhardt_admin::pages::components::common::pagination;
165/// use reinhardt_pages::Signal;
166///
167/// let current_page = Signal::new(1u64);
168/// pagination(current_page, 10)
169/// ```
170pub fn pagination(current_page: Signal<u64>, total_pages: u64) -> Page {
171	let current_val = current_page.get();
172	let mut nav_items = Vec::new();
173
174	// Previous button
175	let prev_disabled = current_val <= 1;
176	nav_items.push(create_page_item(
177		"Previous",
178		prev_disabled,
179		false,
180		current_page.clone(),
181		move |page: Signal<u64>| {
182			let current = page.get();
183			if current > 1 {
184				page.set(current - 1);
185			}
186		},
187	));
188
189	// Page numbers (show up to 5 pages around current)
190	let start = current_val.saturating_sub(2).max(1);
191	let end = (current_val + 2).min(total_pages);
192
193	for page_num in start..=end {
194		let is_current = page_num == current_val;
195		let page_num_str = page_num.to_string();
196		nav_items.push(create_page_item(
197			&page_num_str,
198			false,
199			is_current,
200			current_page.clone(),
201			move |page: Signal<u64>| {
202				page.set(page_num);
203			},
204		));
205	}
206
207	// Next button
208	let next_disabled = current_val >= total_pages;
209	nav_items.push(create_page_item(
210		"Next",
211		next_disabled,
212		false,
213		current_page,
214		move |page: Signal<u64>| {
215			let current = page.get();
216			if current < total_pages {
217				page.set(current + 1);
218			}
219		},
220	));
221
222	page!(|nav_items: Vec<Page>| {
223		div {
224			class: "flex justify-center gap-1 mt-6",
225			{ nav_items }
226		}
227	})(nav_items)
228}
229
230/// Helper function to create a pagination item with event handler
231fn create_page_item<F>(
232	text: &str,
233	disabled: bool,
234	active: bool,
235	signal: Signal<u64>,
236	handler: F,
237) -> Page
238where
239	F: Fn(Signal<u64>) + 'static,
240{
241	let text = text.to_string();
242
243	if disabled {
244		page!(|text: String| {
245			span {
246				class: "admin-page-link admin-page-link-disabled",
247				aria_disabled: "true",
248				tabindex: (-1_i32).to_string(),
249				{ text }
250			}
251		})(text)
252	} else if active {
253		page!(|text: String| {
254			span {
255				class: "admin-page-link admin-page-link-active",
256				aria_current: "page",
257				{ text }
258			}
259		})(text)
260	} else {
261		let handler: Arc<dyn Fn(Signal<u64>)> = Arc::new(handler);
262		page!(|text: String, _signal: Signal<u64>, _handler: Arc<dyn Fn(Signal<u64>)>| {
263			a {
264				class: "admin-page-link",
265				href: "#",
266				@click: move |_| {
267					_handler(_signal.clone());
268				},
269				{ text }
270			}
271		})(text, signal, handler)
272	}
273}
274
275/// Search bar component
276///
277/// Displays a search input with icon.
278/// The current value is displayed from the Signal.
279///
280/// Note: Input value updates must be handled via form binding or external mechanisms.
281/// This component only displays the current Signal value.
282///
283/// # Example
284///
285/// ```ignore
286/// use reinhardt_admin::pages::components::common::search_bar;
287/// use reinhardt_pages::Signal;
288///
289/// let search_value = Signal::new(String::new());
290/// search_bar(search_value, "Search...")
291/// ```
292pub fn search_bar(value: Signal<String>, placeholder: &str) -> Page {
293	let current_value = value.get();
294	let placeholder = placeholder.to_string();
295
296	page!(|placeholder: String, current_value: String| {
297		div {
298			class: "flex",
299			span {
300				class: "flex items-center px-3 bg-slate-100 border border-r-0 border-slate-200 rounded-l-lg text-slate-400 text-sm",
301				"🔍"
302			}
303			input {
304				class: "admin-input rounded-l-none border-l-0",
305				type: "text",
306				placeholder: placeholder,
307				value: current_value,
308			}
309		}
310	})(placeholder, current_value)
311}