Skip to main content

dioxus_ui_system/organisms/
cards.rs

1//! Advanced Card organism components
2//!
3//! Provides 10+ card variations with different layouts, actions, and content types.
4
5use dioxus::prelude::*;
6
7use crate::atoms::{
8    Button, ButtonSize, ButtonVariant, Heading, HeadingLevel, Icon, IconColor, IconSize, Label,
9    TextSize,
10};
11use crate::molecules::{
12    Badge, BadgeVariant, Card, CardContent, CardFooter, CardFooterJustify, CardHeader, CardVariant,
13};
14
15/// ============================================================================
16/// 1. Single Action Card
17/// ============================================================================
18/// Card with a single primary action button
19#[derive(Props, Clone, PartialEq)]
20pub struct ActionCardProps {
21    /// Card title
22    pub title: String,
23    /// Card description
24    pub description: String,
25    /// Action button text
26    pub action_label: String,
27    /// Action button callback
28    pub on_action: EventHandler<()>,
29    /// Optional icon name
30    #[props(default)]
31    pub icon: Option<String>,
32    /// Card variant
33    #[props(default)]
34    pub variant: CardVariant,
35    /// Optional badge
36    #[props(default)]
37    pub badge: Option<String>,
38}
39
40#[component]
41pub fn ActionCard(props: ActionCardProps) -> Element {
42    rsx! {
43        Card {
44            variant: props.variant,
45            full_width: true,
46
47            CardContent {
48                div {
49                    style: "display: flex; flex-direction: column; gap: 12px;",
50
51                    if let Some(icon) = props.icon.clone() {
52                        div {
53                            style: "width: 48px; height: 48px; background: #f1f5f9; border-radius: 12px; display: flex; align-items: center; justify-content: center;",
54
55                            Icon {
56                                name: icon,
57                                size: IconSize::Large,
58                                color: IconColor::Primary,
59                            }
60                        }
61                    }
62
63                    if let Some(badge) = props.badge.clone() {
64                        Badge {
65                            variant: BadgeVariant::Secondary,
66                            "{badge}"
67                        }
68                    }
69
70                    Heading {
71                        level: HeadingLevel::H4,
72                        "{props.title}"
73                    }
74
75                    p {
76                        style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
77                        "{props.description}"
78                    }
79
80                    Button {
81                        variant: ButtonVariant::Primary,
82                        full_width: true,
83                        onclick: move |_| props.on_action.call(()),
84                        "{props.action_label}"
85                    }
86                }
87            }
88        }
89    }
90}
91
92/// ============================================================================
93/// 2. Dual Action Card
94/// ============================================================================
95/// Card with two action buttons (primary and secondary)
96#[derive(Props, Clone, PartialEq)]
97pub struct DualActionCardProps {
98    /// Card title
99    pub title: String,
100    /// Card description
101    pub description: String,
102    /// Primary action label
103    pub primary_label: String,
104    /// Secondary action label
105    pub secondary_label: String,
106    /// Primary action callback
107    pub on_primary: EventHandler<()>,
108    /// Secondary action callback
109    pub on_secondary: EventHandler<()>,
110    /// Optional icon
111    #[props(default)]
112    pub icon: Option<String>,
113}
114
115#[component]
116pub fn DualActionCard(props: DualActionCardProps) -> Element {
117    rsx! {
118        Card {
119            variant: CardVariant::Default,
120            full_width: true,
121
122            CardContent {
123                div {
124                    style: "display: flex; flex-direction: column; gap: 12px;",
125
126                    if let Some(icon) = props.icon.clone() {
127                        div {
128                            style: "width: 48px; height: 48px; background: #f1f5f9; border-radius: 12px; display: flex; align-items: center; justify-content: center;",
129
130                            Icon {
131                                name: icon,
132                                size: IconSize::Large,
133                                color: IconColor::Primary,
134                            }
135                        }
136                    }
137
138                    Heading {
139                        level: HeadingLevel::H4,
140                        "{props.title}"
141                    }
142
143                    p {
144                        style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
145                        "{props.description}"
146                    }
147                }
148            }
149
150            CardFooter {
151                justify: CardFooterJustify::Between,
152
153                Button {
154                    variant: ButtonVariant::Ghost,
155                    onclick: move |_| props.on_secondary.call(()),
156                    "{props.secondary_label}"
157                }
158
159                Button {
160                    variant: ButtonVariant::Primary,
161                    onclick: move |_| props.on_primary.call(()),
162                    "{props.primary_label}"
163                }
164            }
165        }
166    }
167}
168
169/// ============================================================================
170/// 3. Image Card
171/// ============================================================================
172/// Card with an image header
173#[derive(Props, Clone, PartialEq)]
174pub struct ImageCardProps {
175    /// Image URL
176    pub image_url: String,
177    /// Image alt text
178    #[props(default)]
179    pub image_alt: String,
180    /// Card title
181    pub title: String,
182    /// Card description
183    pub description: String,
184    /// Optional action
185    #[props(default)]
186    pub action_label: Option<String>,
187    /// Action callback
188    #[props(default)]
189    pub on_action: Option<EventHandler<()>>,
190    /// Image aspect ratio (default: 16/9)
191    #[props(default = "16/9".to_string())]
192    pub aspect_ratio: String,
193}
194
195#[component]
196pub fn ImageCard(props: ImageCardProps) -> Element {
197    rsx! {
198        Card {
199            variant: CardVariant::Default,
200            full_width: true,
201            padding: Some("0".to_string()),
202
203            // Image
204            div {
205                style: "width: 100%; aspect-ratio: {props.aspect_ratio}; overflow: hidden;",
206
207                img {
208                    src: "{props.image_url}",
209                    alt: "{props.image_alt}",
210                    style: "width: 100%; height: 100%; object-fit: cover;",
211                }
212            }
213
214            CardContent {
215                div {
216                    style: "display: flex; flex-direction: column; gap: 8px; padding: 4px 0;",
217
218                    Heading {
219                        level: HeadingLevel::H4,
220                        "{props.title}"
221                    }
222
223                    p {
224                        style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
225                        "{props.description}"
226                    }
227
228                    if let Some(label) = props.action_label.clone() {
229                        if let Some(handler) = props.on_action.clone() {
230                            Button {
231                                variant: ButtonVariant::Ghost,
232                                onclick: move |_| handler.call(()),
233                                "{label} →"
234                            }
235                        }
236                    }
237                }
238            }
239        }
240    }
241}
242
243/// ============================================================================
244/// 4. Image Card with Actions
245/// ============================================================================
246/// Card with image and action buttons overlay
247#[derive(Props, Clone, PartialEq)]
248pub struct ImageActionCardProps {
249    /// Image URL
250    pub image_url: String,
251    /// Card title
252    pub title: String,
253    /// Card description
254    pub description: String,
255    /// Primary action
256    pub primary_label: String,
257    /// Secondary action
258    pub secondary_label: String,
259    /// Primary callback
260    pub on_primary: EventHandler<()>,
261    /// Secondary callback
262    pub on_secondary: EventHandler<()>,
263    /// Optional badge on image
264    #[props(default)]
265    pub badge: Option<String>,
266}
267
268#[component]
269pub fn ImageActionCard(props: ImageActionCardProps) -> Element {
270    rsx! {
271        Card {
272            variant: CardVariant::Default,
273            full_width: true,
274            padding: Some("0".to_string()),
275
276            // Image container with badge
277            div {
278                style: "position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden;",
279
280                img {
281                    src: "{props.image_url}",
282                    alt: "{props.title}",
283                    style: "width: 100%; height: 100%; object-fit: cover;",
284                }
285
286                if let Some(badge) = props.badge.clone() {
287                    div {
288                        style: "position: absolute; top: 12px; left: 12px;",
289
290                        Badge {
291                            variant: BadgeVariant::Success,
292                            "{badge}"
293                        }
294                    }
295                }
296            }
297
298            CardContent {
299                div {
300                    style: "display: flex; flex-direction: column; gap: 12px; padding: 4px 0;",
301
302                    Heading {
303                        level: HeadingLevel::H4,
304                        "{props.title}"
305                    }
306
307                    p {
308                        style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
309                        "{props.description}"
310                    }
311                }
312            }
313
314            CardFooter {
315                justify: CardFooterJustify::Between,
316
317                Button {
318                    variant: ButtonVariant::Secondary,
319                    onclick: move |_| props.on_secondary.call(()),
320                    "{props.secondary_label}"
321                }
322
323                Button {
324                    variant: ButtonVariant::Primary,
325                    onclick: move |_| props.on_primary.call(()),
326                    "{props.primary_label}"
327                }
328            }
329        }
330    }
331}
332
333/// ============================================================================
334/// 5. Profile Card
335/// ============================================================================
336/// Card showing user/profile information
337#[derive(Props, Clone, PartialEq)]
338pub struct ProfileCardProps {
339    /// Avatar URL
340    #[props(default)]
341    pub avatar_url: Option<String>,
342    /// User name
343    pub name: String,
344    /// User role/title
345    #[props(default)]
346    pub role: Option<String>,
347    /// User description
348    #[props(default)]
349    pub description: Option<String>,
350    /// Follow/Connect button label
351    #[props(default = "Connect".to_string())]
352    pub action_label: String,
353    /// Action callback
354    #[props(default)]
355    pub on_action: Option<EventHandler<()>>,
356    /// Show social stats
357    #[props(default)]
358    pub stats: Vec<(String, String)>,
359}
360
361#[component]
362pub fn ProfileCard(props: ProfileCardProps) -> Element {
363    let initials: String = props
364        .name
365        .split_whitespace()
366        .filter_map(|s| s.chars().next())
367        .collect::<String>()
368        .to_uppercase()
369        .chars()
370        .take(2)
371        .collect();
372
373    rsx! {
374        Card {
375            variant: CardVariant::Default,
376            full_width: true,
377
378            CardContent {
379                div {
380                    style: "display: flex; flex-direction: column; align-items: center; gap: 16px; text-align: center;",
381
382                    // Avatar
383                    if let Some(url) = props.avatar_url.clone() {
384                        img {
385                            src: "{url}",
386                            alt: "{props.name}",
387                            style: "width: 80px; height: 80px; border-radius: 50%; object-fit: cover;",
388                        }
389                    } else {
390                        div {
391                            style: "width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 24px;",
392                            "{initials}"
393                        }
394                    }
395
396                    // Info
397                    div {
398                        Heading {
399                            level: HeadingLevel::H4,
400                            "{props.name}"
401                        }
402
403                        if let Some(role) = props.role.clone() {
404                            Label {
405                                size: TextSize::Small,
406                                color: crate::atoms::TextColor::Muted,
407                                "{role}"
408                            }
409                        }
410                    }
411
412                    if let Some(desc) = props.description.clone() {
413                        p {
414                            style: "margin: 0; color: #64748b; font-size: 14px; line-height: 1.5;",
415                            "{desc}"
416                        }
417                    }
418
419                    // Stats
420                    if !props.stats.is_empty() {
421                        div {
422                            style: "display: flex; gap: 24px; justify-content: center; width: 100%; padding-top: 8px; border-top: 1px solid #e2e8f0;",
423
424                            for (label, value) in props.stats.clone() {
425                                div {
426                                    style: "text-align: center;",
427
428                                    div {
429                                        style: "font-size: 18px; font-weight: 700; color: #0f172a;",
430                                        "{value}"
431                                    }
432
433                                    Label {
434                                        size: TextSize::ExtraSmall,
435                                        color: crate::atoms::TextColor::Muted,
436                                        "{label}"
437                                    }
438                                }
439                            }
440                        }
441                    }
442
443                    // Action
444                    if let Some(handler) = props.on_action.clone() {
445                        Button {
446                            variant: ButtonVariant::Primary,
447                            full_width: true,
448                            onclick: move |_| handler.call(()),
449                            "{props.action_label}"
450                        }
451                    }
452                }
453            }
454        }
455    }
456}
457
458/// ============================================================================
459/// 6. Pricing Card
460/// ============================================================================
461/// Pricing plan card
462#[derive(Props, Clone, PartialEq)]
463pub struct PricingCardProps {
464    /// Plan name
465    pub plan: String,
466    /// Price (e.g., "$29")
467    pub price: String,
468    /// Billing period (e.g., "/month")
469    #[props(default = "/month".to_string())]
470    pub period: String,
471    /// Plan description
472    #[props(default)]
473    pub description: Option<String>,
474    /// List of features
475    pub features: Vec<String>,
476    /// CTA button label
477    #[props(default = "Get Started".to_string())]
478    pub cta_label: String,
479    /// CTA callback
480    pub on_cta: EventHandler<()>,
481    /// Is this the recommended plan
482    #[props(default)]
483    pub recommended: bool,
484}
485
486#[component]
487pub fn PricingCard(props: PricingCardProps) -> Element {
488    let action_element: Option<Element> = if props.recommended {
489        Some(rsx! {
490            Badge {
491                variant: BadgeVariant::Success,
492                "Recommended"
493            }
494        })
495    } else {
496        None
497    };
498
499    rsx! {
500        Card {
501            variant: if props.recommended { CardVariant::Elevated } else { CardVariant::Default },
502            full_width: true,
503
504            CardHeader {
505                title: props.plan.clone(),
506                action: action_element
507            }
508
509            CardContent {
510                div {
511                    style: "display: flex; flex-direction: column; gap: 16px;",
512
513                    // Price
514                    div {
515                        style: "text-align: center;",
516
517                        span {
518                            style: "font-size: 36px; font-weight: 800; color: #0f172a;",
519                            "{props.price}"
520                        }
521
522                        span {
523                            style: "color: #64748b; font-size: 14px;",
524                            "{props.period}"
525                        }
526                    }
527
528                    if let Some(desc) = props.description.clone() {
529                        p {
530                            style: "margin: 0; color: #64748b; font-size: 14px; text-align: center;",
531                            "{desc}"
532                        }
533                    }
534
535                    // Features
536                    ul {
537                        style: "margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px;",
538
539                        for feature in props.features.clone() {
540                            li {
541                                style: "display: flex; align-items: center; gap: 8px; font-size: 14px;",
542
543                                Icon {
544                                    name: "check".to_string(),
545                                    size: IconSize::Small,
546                                    color: IconColor::Success,
547                                }
548
549                                "{feature}"
550                            }
551                        }
552                    }
553                }
554            }
555
556            CardFooter {
557                Button {
558                    variant: if props.recommended { ButtonVariant::Primary } else { ButtonVariant::Secondary },
559                    full_width: true,
560                    onclick: move |_| props.on_cta.call(()),
561                    "{props.cta_label}"
562                }
563            }
564        }
565    }
566}
567
568/// ============================================================================
569/// 7. Horizontal Card
570/// ============================================================================
571/// Card with horizontal layout (image left, content right)
572#[derive(Props, Clone, PartialEq)]
573pub struct HorizontalCardProps {
574    /// Image URL
575    pub image_url: String,
576    /// Card title
577    pub title: String,
578    /// Card description
579    pub description: String,
580    /// Optional action
581    #[props(default)]
582    pub action_label: Option<String>,
583    /// Action callback
584    #[props(default)]
585    pub on_action: Option<EventHandler<()>>,
586}
587
588#[component]
589pub fn HorizontalCard(props: HorizontalCardProps) -> Element {
590    rsx! {
591        Card {
592            variant: CardVariant::Default,
593            full_width: true,
594            padding: Some("0".to_string()),
595
596            div {
597                style: "display: flex; flex-direction: row;",
598
599                // Image (left side)
600                div {
601                    style: "width: 120px; min-height: 120px; flex-shrink: 0;",
602
603                    img {
604                        src: "{props.image_url}",
605                        alt: "{props.title}",
606                        style: "width: 100%; height: 100%; object-fit: cover; border-radius: 12px 0 0 12px;",
607                    }
608                }
609
610                // Content (right side)
611                div {
612                    style: "flex: 1; padding: 16px; display: flex; flex-direction: column; justify-content: center;",
613
614                    Heading {
615                        level: HeadingLevel::H4,
616                        "{props.title}"
617                    }
618
619                    p {
620                        style: "margin: 4px 0 0 0; color: #64748b; font-size: 13px; line-height: 1.4;",
621                        "{props.description}"
622                    }
623
624                    if let Some(label) = props.action_label.clone() {
625                        if let Some(handler) = props.on_action.clone() {
626                            div {
627                                style: "margin-top: 12px;",
628
629                                Button {
630                                    variant: ButtonVariant::Ghost,
631                                    size: ButtonSize::Sm,
632                                    onclick: move |_| handler.call(()),
633                                    "{label} →"
634                                }
635                            }
636                        }
637                    }
638                }
639            }
640        }
641    }
642}
643
644/// ============================================================================
645/// 8. Notification Card
646/// ============================================================================
647/// Card for notifications/alerts with dismiss action
648#[derive(Props, Clone, PartialEq)]
649pub struct NotificationCardProps {
650    /// Notification title
651    pub title: String,
652    /// Notification message
653    pub message: String,
654    /// Notification type
655    #[props(default)]
656    pub notification_type: NotificationType,
657    /// Timestamp
658    #[props(default)]
659    pub timestamp: Option<String>,
660    /// Dismiss callback
661    #[props(default)]
662    pub on_dismiss: Option<EventHandler<()>>,
663    /// Icon name (auto-selected based on type if not provided)
664    #[props(default)]
665    pub icon: Option<String>,
666}
667
668/// Notification type variants
669#[derive(Clone, PartialEq, Default)]
670pub enum NotificationType {
671    #[default]
672    Info,
673    Success,
674    Warning,
675    Error,
676}
677
678#[component]
679pub fn NotificationCard(props: NotificationCardProps) -> Element {
680    let (icon, icon_color, border_color) = match props.notification_type {
681        NotificationType::Info => ("info", IconColor::Primary, "#3b82f6"),
682        NotificationType::Success => ("check-circle", IconColor::Success, "#22c55e"),
683        NotificationType::Warning => ("alert-triangle", IconColor::Warning, "#eab308"),
684        NotificationType::Error => ("x-circle", IconColor::Destructive, "#ef4444"),
685    };
686
687    let icon_name = props.icon.clone().unwrap_or_else(|| icon.to_string());
688
689    rsx! {
690        Card {
691            variant: CardVariant::Default,
692            full_width: true,
693            style: Some(format!("border-left: 4px solid {};", border_color)),
694
695            CardContent {
696                div {
697                    style: "display: flex; gap: 12px;",
698
699                    // Icon
700                    div {
701                        style: "flex-shrink: 0; padding-top: 2px;",
702
703                        Icon {
704                            name: icon_name,
705                            size: IconSize::Medium,
706                            color: icon_color,
707                        }
708                    }
709
710                    // Content
711                    div {
712                        style: "flex: 1; min-width: 0;",
713
714                        div {
715                            style: "display: flex; justify-content: space-between; align-items: flex-start; gap: 8px;",
716
717                            Heading {
718                                level: HeadingLevel::H4,
719                                "{props.title}"
720                            }
721
722                            if let Some(handler) = props.on_dismiss.clone() {
723                                button {
724                                    style: "background: none; border: none; cursor: pointer; padding: 4px; color: #94a3b8;",
725                                    onclick: move |_| handler.call(()),
726                                    "✕"
727                                }
728                            }
729                        }
730
731                        p {
732                            style: "margin: 4px 0 0 0; color: #64748b; font-size: 14px; line-height: 1.5;",
733                            "{props.message}"
734                        }
735
736                        if let Some(time) = props.timestamp.clone() {
737                            Label {
738                                size: TextSize::ExtraSmall,
739                                color: crate::atoms::TextColor::Muted,
740                                "{time}"
741                            }
742                        }
743                    }
744                }
745            }
746        }
747    }
748}
749
750/// ============================================================================
751/// 9. Stat Card
752/// ============================================================================
753/// Card for displaying statistics/metrics
754#[derive(Props, Clone, PartialEq)]
755pub struct StatCardProps {
756    /// Stat label
757    pub label: String,
758    /// Stat value
759    pub value: String,
760    /// Change indicator (e.g., "+12%")
761    #[props(default)]
762    pub change: Option<String>,
763    /// Is the change positive
764    #[props(default)]
765    pub change_positive: Option<bool>,
766    /// Icon name
767    #[props(default)]
768    pub icon: Option<String>,
769    /// Icon background color
770    #[props(default = "#f1f5f9".to_string())]
771    pub icon_bg: String,
772}
773
774#[component]
775pub fn StatCard(props: StatCardProps) -> Element {
776    let is_positive = props.change_positive.unwrap_or(true);
777    let change_color = if is_positive { "#22c55e" } else { "#ef4444" };
778
779    rsx! {
780        Card {
781            variant: CardVariant::Default,
782            full_width: true,
783
784            CardContent {
785                div {
786                    style: "display: flex; align-items: flex-start; justify-content: space-between;",
787
788                    // Text content
789                    div {
790                        Label {
791                            size: TextSize::Small,
792                            color: crate::atoms::TextColor::Muted,
793                            "{props.label}"
794                        }
795
796                        div {
797                            style: "display: flex; align-items: baseline; gap: 8px; margin-top: 4px;",
798
799                            span {
800                                style: "font-size: 28px; font-weight: 700; color: #0f172a;",
801                                "{props.value}"
802                            }
803
804                            if let Some(change) = props.change.clone() {
805                                span {
806                                    style: "font-size: 13px; font-weight: 600; color: {change_color};",
807                                    "{change}"
808                                }
809                            }
810                        }
811                    }
812
813                    // Icon
814                    if let Some(icon) = props.icon.clone() {
815                        div {
816                            style: "width: 40px; height: 40px; background: {props.icon_bg}; border-radius: 10px; display: flex; align-items: center; justify-content: center;",
817
818                            Icon {
819                                name: icon,
820                                size: IconSize::Medium,
821                                color: IconColor::Primary,
822                            }
823                        }
824                    }
825                }
826            }
827        }
828    }
829}
830
831/// ============================================================================
832/// 10. Expandable Card
833/// ============================================================================
834/// Card that can expand/collapse to show more content
835#[derive(Props, Clone, PartialEq)]
836pub struct ExpandableCardProps {
837    /// Card title
838    pub title: String,
839    /// Preview content (always visible)
840    pub preview: Element,
841    /// Expanded content (shown when expanded)
842    pub expanded_content: Element,
843    /// Initial expanded state
844    #[props(default)]
845    pub default_expanded: bool,
846}
847
848#[component]
849pub fn ExpandableCard(props: ExpandableCardProps) -> Element {
850    let mut is_expanded = use_signal(|| props.default_expanded);
851
852    rsx! {
853        Card {
854            variant: CardVariant::Default,
855            full_width: true,
856
857            CardHeader {
858                title: props.title.clone(),
859
860                action: rsx! {
861                    button {
862                        style: "background: none; border: none; cursor: pointer; padding: 4px; transition: transform 200ms;",
863                        style: if is_expanded() { "transform: rotate(180deg);" } else { "" },
864                        onclick: move |_| is_expanded.toggle(),
865
866                        Icon {
867                            name: "chevron-down".to_string(),
868                            size: IconSize::Medium,
869                            color: IconColor::Muted,
870                        }
871                    }
872                }
873            }
874
875            CardContent {
876                {props.preview}
877
878                if is_expanded() {
879                    div {
880                        style: "margin-top: 16px; padding-top: 16px; border-top: 1px solid #e2e8f0; animation: fadeIn 200ms ease;",
881                        {props.expanded_content}
882                    }
883                }
884            }
885        }
886    }
887}
888
889/// ============================================================================
890/// 11. Media Card (Bonus)
891/// ============================================================================
892/// Card optimized for media content with overlay controls
893#[derive(Props, Clone, PartialEq)]
894pub struct MediaCardProps {
895    /// Media URL (image or video thumbnail)
896    pub media_url: String,
897    /// Media type indicator
898    #[props(default)]
899    pub media_type: MediaType,
900    /// Card title
901    pub title: String,
902    /// Creator/Author
903    #[props(default)]
904    pub creator: Option<String>,
905    /// Duration (for video/audio)
906    #[props(default)]
907    pub duration: Option<String>,
908    /// Like callback
909    #[props(default)]
910    pub on_like: Option<EventHandler<()>>,
911    /// Share callback
912    #[props(default)]
913    pub on_share: Option<EventHandler<()>>,
914    /// Play callback
915    #[props(default)]
916    pub on_play: Option<EventHandler<()>>,
917}
918
919/// Media type
920#[derive(Clone, PartialEq, Default)]
921pub enum MediaType {
922    #[default]
923    Image,
924    Video,
925    Audio,
926}
927
928#[component]
929pub fn MediaCard(props: MediaCardProps) -> Element {
930    let overlay_icon = match props.media_type {
931        MediaType::Image => None,
932        MediaType::Video => Some("play-circle"),
933        MediaType::Audio => Some("play-circle"),
934    };
935
936    rsx! {
937        Card {
938            variant: CardVariant::Default,
939            full_width: true,
940            padding: Some("0".to_string()),
941
942            // Media container
943            div {
944                style: "position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden;",
945
946                img {
947                    src: "{props.media_url}",
948                    alt: "{props.title}",
949                    style: "width: 100%; height: 100%; object-fit: cover;",
950                }
951
952                // Play overlay for video/audio
953                if let Some(icon) = overlay_icon {
954                    if let Some(handler) = props.on_play.clone() {
955                        div {
956                            style: "position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.3); cursor: pointer;",
957                            onclick: move |_| handler.call(()),
958
959                            div {
960                                style: "width: 60px; height: 60px; background: white; border-radius: 50%; display: flex; align-items: center; justify-content: center;",
961
962                                Icon {
963                                    name: icon.to_string(),
964                                    size: IconSize::Large,
965                                    color: IconColor::Primary,
966                                }
967                            }
968                        }
969                    }
970                }
971
972                // Duration badge
973                if let Some(duration) = props.duration.clone() {
974                    div {
975                        style: "position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.7); color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;",
976                        "{duration}"
977                    }
978                }
979            }
980
981            // Content
982            CardContent {
983                div {
984                    style: "display: flex; justify-content: space-between; align-items: flex-start; gap: 8px;",
985
986                    div {
987                        style: "flex: 1; min-width: 0;",
988
989                        Heading {
990                            level: HeadingLevel::H4,
991                            "{props.title}"
992                        }
993
994                        if let Some(creator) = props.creator.clone() {
995                            Label {
996                                size: TextSize::Small,
997                                color: crate::atoms::TextColor::Muted,
998                                "{creator}"
999                            }
1000                        }
1001                    }
1002
1003                    // Action buttons
1004                    div {
1005                        style: "display: flex; gap: 8px;",
1006
1007                        if let Some(handler) = props.on_like.clone() {
1008                            button {
1009                                style: "background: none; border: none; cursor: pointer; padding: 4px;",
1010                                onclick: move |_| handler.call(()),
1011
1012                                Icon {
1013                                    name: "heart".to_string(),
1014                                    size: IconSize::Medium,
1015                                    color: IconColor::Muted,
1016                                }
1017                            }
1018                        }
1019
1020                        if let Some(handler) = props.on_share.clone() {
1021                            button {
1022                                style: "background: none; border: none; cursor: pointer; padding: 4px;",
1023                                onclick: move |_| handler.call(()),
1024
1025                                Icon {
1026                                    name: "share".to_string(),
1027                                    size: IconSize::Medium,
1028                                    color: IconColor::Muted,
1029                                }
1030                            }
1031                        }
1032                    }
1033                }
1034            }
1035        }
1036    }
1037}