Skip to main content

dioxus_mdx/components/
card.rs

1//! Card and CardGroup components for documentation.
2
3use dioxus::prelude::*;
4
5use crate::components::MdxIcon;
6use crate::parser::{CardGroupNode, CardNode};
7
8/// Props for DocCardGroup component.
9#[derive(Props, Clone, PartialEq)]
10pub struct DocCardGroupProps {
11    /// Card group data.
12    pub group: CardGroupNode,
13    /// Optional callback for internal link clicks.
14    /// If provided, internal links call this instead of using `<a>`.
15    #[props(optional)]
16    pub on_link: Option<EventHandler<String>>,
17    /// Base path for doc links (e.g., "/docs").
18    #[props(default = "/docs".to_string())]
19    pub doc_base_path: String,
20}
21
22/// Grid of cards component.
23#[component]
24pub fn DocCardGroup(props: DocCardGroupProps) -> Element {
25    let grid_class = match props.group.cols {
26        1 => "grid grid-cols-1 gap-4",
27        2 => "grid grid-cols-1 md:grid-cols-2 gap-4",
28        3 => "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4",
29        _ => "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4",
30    };
31
32    rsx! {
33        div { class: "my-6 {grid_class}",
34            for (i, card) in props.group.cards.iter().enumerate() {
35                DocCard {
36                    key: "{i}",
37                    card: card.clone(),
38                    on_link: props.on_link,
39                    doc_base_path: props.doc_base_path.clone(),
40                }
41            }
42        }
43    }
44}
45
46/// Props for DocCard component.
47#[derive(Props, Clone, PartialEq)]
48pub struct DocCardProps {
49    /// Card data.
50    pub card: CardNode,
51    /// Optional callback for internal link clicks.
52    #[props(optional)]
53    pub on_link: Option<EventHandler<String>>,
54    /// Base path for doc links.
55    #[props(default = "/docs".to_string())]
56    pub doc_base_path: String,
57}
58
59/// Individual card component.
60#[component]
61pub fn DocCard(props: DocCardProps) -> Element {
62    // Render markdown content
63    let html = if !props.card.content.is_empty() {
64        markdown::to_html_with_options(&props.card.content, &markdown::Options::gfm())
65            .unwrap_or_else(|_| props.card.content.clone())
66    } else {
67        String::new()
68    };
69
70    let card_content = rsx! {
71        div { class: "bg-base-300 hover:border-primary/50 transition-colors duration-150 border border-base-content/10 rounded-lg h-full",
72            div { class: "p-6",
73                // Icon on top
74                if let Some(icon) = &props.card.icon {
75                    div { class: "text-primary mb-5",
76                        MdxIcon { name: icon.clone(), class: "size-6".to_string() }
77                    }
78                }
79                // Title - no underline
80                h3 { class: "font-semibold text-base-content mb-2 no-underline",
81                    "{props.card.title}"
82                }
83                // Content/Description - no underlines, plain text color
84                if !html.is_empty() {
85                    div {
86                        class: "text-sm text-base-content/60 leading-relaxed [&>p]:my-0 [&_a]:no-underline [&_a]:text-base-content/60",
87                        dangerous_inner_html: html,
88                    }
89                }
90            }
91        }
92    };
93
94    // Wrap in link if href is present
95    if let Some(href) = &props.card.href {
96        // Handle internal vs external links
97        if href.starts_with("http://") || href.starts_with("https://") {
98            rsx! {
99                a {
100                    href: "{href}",
101                    target: "_blank",
102                    rel: "noopener noreferrer",
103                    class: "block no-underline hover:no-underline not-prose",
104                    {card_content}
105                }
106            }
107        } else {
108            // Convert Mintlify-style paths to internal routing
109            let internal_href = convert_doc_href(href, &props.doc_base_path);
110
111            if let Some(on_link) = &props.on_link {
112                let href_for_click = internal_href.clone();
113                let on_link = *on_link;
114                rsx! {
115                    button {
116                        class: "block no-underline text-left w-full not-prose",
117                        onclick: move |_| on_link.call(href_for_click.clone()),
118                        {card_content}
119                    }
120                }
121            } else {
122                rsx! {
123                    a {
124                        href: "{internal_href}",
125                        class: "block no-underline hover:no-underline not-prose",
126                        {card_content}
127                    }
128                }
129            }
130        }
131    } else {
132        card_content
133    }
134}
135
136/// Convert Mintlify-style doc paths to internal routing.
137fn convert_doc_href(href: &str, base_path: &str) -> String {
138    // If the href already starts with the base path, return as-is
139    if href.starts_with(base_path) {
140        return href.to_string();
141    }
142    // Remove leading slash if present
143    let path = href.trim_start_matches('/');
144    format!("{}/{}", base_path, path)
145}