skill_web/components/
card.rs

1//! Card component for content containers
2
3use yew::prelude::*;
4
5/// Card component props
6#[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/// Card component for grouping content
23#[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/// Simple stat card for dashboard
62#[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/// Stat card component for displaying metrics
82#[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}