qorzen_oxide/ui/pages/
mod.rs

1// src/ui/pages/mod.rs - Page components module
2
3use dioxus::prelude::*;
4
5// Module declarations
6mod admin;
7mod dashboard;
8mod login;
9mod not_found;
10mod plugins;
11mod profile;
12mod settings;
13
14// Re-exports
15pub use admin::Admin;
16pub use dashboard::Dashboard;
17pub use login::Login;
18pub use not_found::NotFound;
19pub use plugins::{PluginView, Plugins};
20pub use profile::Profile;
21pub use settings::Settings;
22
23/// Common page wrapper component
24#[component]
25pub fn PageWrapper(
26    #[props(default = "".to_string())] title: String,
27    #[props(default = None)] subtitle: Option<String>,
28    #[props(default = None)] actions: Option<Element>,
29    #[props(default = "".to_string())] class: String,
30    children: Element,
31) -> Element {
32    rsx! {
33        div {
34            class: format!("space-y-6 {}", class),
35
36            // Page header
37            if !title.is_empty() {
38                div {
39                    class: "md:flex md:items-center md:justify-between",
40                    div {
41                        class: "flex-1 min-w-0",
42                        h1 {
43                            class: "text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate",
44                            "{title}"
45                        }
46                        if let Some(subtitle) = subtitle {
47                            p {
48                                class: "mt-1 text-sm text-gray-500",
49                                "{subtitle}"
50                            }
51                        }
52                    }
53                    if let Some(actions) = actions {
54                        div {
55                            class: "mt-4 flex md:mt-0 md:ml-4",
56                            {actions}
57                        }
58                    }
59                }
60            }
61
62            // Page content
63            {children}
64        }
65    }
66}
67
68/// Loading skeleton component for pages
69#[component]
70pub fn PageSkeleton() -> Element {
71    rsx! {
72        div {
73            class: "space-y-6 animate-pulse",
74
75            // Title skeleton
76            div {
77                class: "h-8 bg-gray-200 rounded w-1/3"
78            }
79
80            // Content skeletons
81            div {
82                class: "space-y-4",
83                div {
84                    class: "h-4 bg-gray-200 rounded w-3/4"
85                }
86                div {
87                    class: "h-4 bg-gray-200 rounded w-1/2"
88                }
89                div {
90                    class: "h-4 bg-gray-200 rounded w-5/6"
91                }
92            }
93
94            // Card skeletons
95            div {
96                class: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
97                for _ in 0..6 {
98                    div {
99                        class: "bg-white p-6 rounded-lg shadow",
100                        div {
101                            class: "space-y-3",
102                            div {
103                                class: "h-4 bg-gray-200 rounded w-1/2"
104                            }
105                            div {
106                                class: "h-3 bg-gray-200 rounded w-full"
107                            }
108                            div {
109                                class: "h-3 bg-gray-200 rounded w-3/4"
110                            }
111                        }
112                    }
113                }
114            }
115        }
116    }
117}
118
119/// Error state component for pages
120#[component]
121pub fn PageError(
122    #[props(default = "An error occurred".to_string())] message: String,
123    #[props(default = None)] retry_action: Option<Callback<()>>,
124) -> Element {
125    rsx! {
126        div {
127            class: "text-center py-12",
128            div {
129                class: "text-6xl text-red-500 mb-4",
130                "⚠️"
131            }
132            h2 {
133                class: "text-2xl font-bold text-gray-900 mb-2",
134                "Oops! Something went wrong"
135            }
136            p {
137                class: "text-gray-600 mb-6",
138                "{message}"
139            }
140            if let Some(retry) = retry_action {
141                button {
142                    r#type: "button",
143                    class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500",
144                    onclick: move |_| retry.call(()),
145                    "Try Again"
146                }
147            }
148        }
149    }
150}
151
152/// Empty state component for pages
153#[component]
154pub fn EmptyState(
155    #[props(default = "📭".to_string())] icon: String,
156    #[props(default = "No data available".to_string())] title: String,
157    #[props(default = "There's nothing to show here yet.".to_string())] description: String,
158    #[props(default = None)] action: Option<Element>,
159) -> Element {
160    rsx! {
161        div {
162            class: "text-center py-12",
163            div {
164                class: "text-6xl mb-4",
165                "{icon}"
166            }
167            h3 {
168                class: "text-lg font-medium text-gray-900 mb-2",
169                "{title}"
170            }
171            p {
172                class: "text-gray-500 mb-6",
173                "{description}"
174            }
175            if let Some(action) = action {
176                {action}
177            }
178        }
179    }
180}
181
182/// Stat card component for dashboards
183#[component]
184pub fn StatCard(
185    title: String,
186    value: String,
187    #[props(default = None)] change: Option<String>,
188    #[props(default = None)] trend: Option<StatTrend>,
189    #[props(default = None)] icon: Option<String>,
190) -> Element {
191    let trend_color = match trend {
192        Some(StatTrend::Up) => "text-green-600",
193        Some(StatTrend::Down) => "text-red-600",
194        Some(StatTrend::Neutral) => "text-gray-600",
195        None => "text-gray-600",
196    };
197
198    rsx! {
199        div {
200            class: "bg-white overflow-hidden shadow rounded-lg",
201            div {
202                class: "p-5",
203                div {
204                    class: "flex items-center",
205                    div {
206                        class: "flex-shrink-0",
207                        if let Some(icon) = icon {
208                            span {
209                                class: "text-2xl",
210                                "{icon}"
211                            }
212                        }
213                    }
214                    div {
215                        class: "ml-5 w-0 flex-1",
216                        dl {
217                            dt {
218                                class: "text-sm font-medium text-gray-500 truncate",
219                                "{title}"
220                            }
221                            dd {
222                                class: "flex items-baseline",
223                                div {
224                                    class: "text-2xl font-semibold text-gray-900",
225                                    "{value}"
226                                }
227                                if let Some(change_text) = change {
228                                    div {
229                                        class: format!("ml-2 flex items-baseline text-sm font-semibold {}", trend_color),
230                                        match trend {
231                                            Some(StatTrend::Up) => rsx! { "↗ {change_text}" },
232                                            Some(StatTrend::Down) => rsx! { "↘ {change_text}" },
233                                            _ => rsx! { "{change_text}" },
234                                        }
235                                    }
236                                }
237                            }
238                        }
239                    }
240                }
241            }
242        }
243    }
244}
245
246/// Trend direction for stat cards
247#[derive(Debug, Clone, PartialEq)]
248pub enum StatTrend {
249    Up,
250    Down,
251    Neutral,
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_page_wrapper_creation() {
260        let _wrapper = rsx! {
261            PageWrapper {
262                title: "Test Page".to_string(),
263                div { "Content" }
264            }
265        };
266    }
267
268    #[test]
269    fn test_stat_card_creation() {
270        let _card = rsx! {
271            StatCard {
272                title: "Total Users".to_string(),
273                value: "1,234".to_string(),
274                change: Some("+12%".to_string()),
275                trend: Some(StatTrend::Up),
276                icon: Some("👥".to_string())
277            }
278        };
279    }
280
281    #[test]
282    fn test_empty_state_creation() {
283        let _empty = rsx! {
284            EmptyState {
285                icon: "📝".to_string(),
286                title: "No items".to_string(),
287                description: "Create your first item".to_string()
288            }
289        };
290    }
291}