skill_web/components/
card.rs1use yew::prelude::*;
4
5#[derive(Properties, PartialEq)]
7pub struct CardProps {
8 #[prop_or_default]
9 pub children: Children,
10 #[prop_or_default]
11 pub class: Classes,
12 #[prop_or_default]
13 pub title: Option<AttrValue>,
14 #[prop_or_default]
15 pub subtitle: Option<AttrValue>,
16 #[prop_or_default]
17 pub actions: Option<Html>,
18 #[prop_or(false)]
19 pub hoverable: bool,
20}
21
22#[function_component(Card)]
24pub fn card(props: &CardProps) -> Html {
25 let base_class = if props.hoverable {
26 "card-hover"
27 } else {
28 "card"
29 };
30
31 html! {
32 <div class={classes!(base_class, props.class.clone())}>
33 if props.title.is_some() || props.actions.is_some() {
34 <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
35 <div>
36 if let Some(title) = &props.title {
37 <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
38 { title }
39 </h3>
40 }
41 if let Some(subtitle) = &props.subtitle {
42 <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
43 { subtitle }
44 </p>
45 }
46 </div>
47 if let Some(actions) = &props.actions {
48 <div class="flex items-center gap-2">
49 { actions.clone() }
50 </div>
51 }
52 </div>
53 }
54 <div class="p-6">
55 { for props.children.iter() }
56 </div>
57 </div>
58 }
59}
60
61#[derive(Properties, PartialEq)]
63pub struct StatCardProps {
64 pub title: AttrValue,
65 pub value: AttrValue,
66 #[prop_or_default]
67 pub subtitle: Option<AttrValue>,
68 #[prop_or_default]
69 pub icon: Option<Html>,
70 #[prop_or_default]
71 pub trend: Option<Trend>,
72}
73
74#[derive(Clone, PartialEq)]
75pub enum Trend {
76 Up(String),
77 Down(String),
78 Neutral(String),
79}
80
81#[function_component(StatCard)]
83pub fn stat_card(props: &StatCardProps) -> Html {
84 html! {
85 <div class="card p-6">
86 <div class="flex items-start justify-between">
87 <div class="flex-1">
88 <p class="text-sm font-medium text-gray-500 dark:text-gray-400">
89 { &props.title }
90 </p>
91 <p class="mt-2 text-3xl font-semibold text-gray-900 dark:text-white">
92 { &props.value }
93 </p>
94 if let Some(subtitle) = &props.subtitle {
95 <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
96 { subtitle }
97 </p>
98 }
99 if let Some(trend) = &props.trend {
100 <div class="mt-2 flex items-center text-sm">
101 { render_trend(trend) }
102 </div>
103 }
104 </div>
105 if let Some(icon) = &props.icon {
106 <div class="p-3 bg-primary-50 dark:bg-primary-900/30 rounded-lg">
107 { icon.clone() }
108 </div>
109 }
110 </div>
111 </div>
112 }
113}
114
115fn render_trend(trend: &Trend) -> Html {
116 match trend {
117 Trend::Up(text) => html! {
118 <span class="text-success-600 dark:text-green-400 flex items-center gap-1">
119 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
120 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
121 </svg>
122 { text }
123 </span>
124 },
125 Trend::Down(text) => html! {
126 <span class="text-error-600 dark:text-red-400 flex items-center gap-1">
127 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
128 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
129 </svg>
130 { text }
131 </span>
132 },
133 Trend::Neutral(text) => html! {
134 <span class="text-gray-500 dark:text-gray-400">{ text }</span>
135 },
136 }
137}