qorzen_oxide/ui/pages/
mod.rs1use dioxus::prelude::*;
4
5mod admin;
7mod dashboard;
8mod login;
9mod not_found;
10mod plugins;
11mod profile;
12mod settings;
13
14pub 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#[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 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 {children}
64 }
65 }
66}
67
68#[component]
70pub fn PageSkeleton() -> Element {
71 rsx! {
72 div {
73 class: "space-y-6 animate-pulse",
74
75 div {
77 class: "h-8 bg-gray-200 rounded w-1/3"
78 }
79
80 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 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#[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#[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#[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#[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}