Skip to main content

dioxus_ui_system/molecules/
media_object.rs

1//! Media Object molecule component
2//!
3//! Image + text content with flexible alignment.
4
5use crate::theme::use_theme;
6use dioxus::prelude::*;
7
8/// Media alignment
9#[derive(Default, Clone, PartialEq, Debug)]
10pub enum MediaAlign {
11    #[default]
12    Start,
13    Center,
14    End,
15}
16
17/// Media object properties
18#[derive(Props, Clone, PartialEq)]
19pub struct MediaObjectProps {
20    /// Media content (image, icon, avatar)
21    pub media: Element,
22    /// Body content (text)
23    pub children: Element,
24    /// Media alignment
25    #[props(default = MediaAlign::Start)]
26    pub align: MediaAlign,
27    /// Spacing between media and content
28    #[props(default = 16)]
29    pub gap: u16,
30    /// Whether to reverse the order (content first)
31    #[props(default = false)]
32    pub reverse: bool,
33    /// Stack on mobile
34    #[props(default = true)]
35    pub responsive: bool,
36    /// Media width (when stacked)
37    #[props(default)]
38    pub media_width: Option<String>,
39    /// Additional CSS classes
40    #[props(default)]
41    pub class: Option<String>,
42}
43
44/// Media object component (image + text layout)
45#[component]
46pub fn MediaObject(props: MediaObjectProps) -> Element {
47    let class_css = props
48        .class
49        .as_ref()
50        .map(|c| format!(" {}", c))
51        .unwrap_or_default();
52
53    let flex_direction = if props.reverse { "row-reverse" } else { "row" };
54    let align_items = match props.align {
55        MediaAlign::Start => "flex-start",
56        MediaAlign::Center => "center",
57        MediaAlign::End => "flex-end",
58    };
59
60    let responsive_css = if props.responsive {
61        format!("@media (max-width: 640px) {{ .media-object{class_css} {{ flex-direction: column; }} }}")
62    } else {
63        String::new()
64    };
65
66    let media_style = props
67        .media_width
68        .as_ref()
69        .map(|w| format!("flex-shrink: 0; width: {};", w))
70        .unwrap_or_else(|| "flex-shrink: 0;".to_string());
71
72    let gap = props.gap;
73
74    rsx! {
75        div {
76            class: "media-object{class_css}",
77            style: "display: flex; flex-direction: {flex_direction}; align-items: {align_items}; gap: {gap}px;",
78
79            div {
80                class: "media-object-media",
81                style: "{media_style}",
82                {props.media}
83            }
84
85            div {
86                class: "media-object-content",
87                style: "flex: 1; min-width: 0;",
88                {props.children}
89            }
90        }
91
92        if !responsive_css.is_empty() {
93            style { "{{ {responsive_css} }}" }
94        }
95    }
96}
97
98/// Media content properties
99#[derive(Props, Clone, PartialEq)]
100pub struct MediaContentProps {
101    /// Title/heading
102    #[props(default)]
103    pub title: Option<String>,
104    /// Title element level
105    #[props(default = 4)]
106    pub title_level: u8,
107    /// Description/body text
108    #[props(default)]
109    pub description: Option<String>,
110    /// Additional content
111    pub children: Option<Element>,
112    /// Metadata (date, author, etc.)
113    #[props(default)]
114    pub meta: Option<String>,
115    /// Actions (buttons, links)
116    #[props(default)]
117    pub actions: Option<Element>,
118}
119
120/// Media content component (structured text content)
121#[component]
122pub fn MediaContent(props: MediaContentProps) -> Element {
123    let theme = use_theme();
124
125    let _title_tag = format!("h{}", props.title_level.clamp(1, 6));
126    let font_size = match props.title_level {
127        1 => 24,
128        2 => 20,
129        3 => 18,
130        _ => 16,
131    };
132    let title_color = theme.tokens.read().colors.foreground.to_rgba();
133    let title_style = format!(
134        "margin: 0; font-size: {}px; font-weight: 600; color: {}; line-height: 1.3;",
135        font_size, title_color
136    );
137
138    rsx! {
139        div {
140            class: "media-content",
141            style: "display: flex; flex-direction: column; gap: 8px;",
142
143            if let Some(title) = props.title.clone() {
144                match props.title_level {
145                    1 => rsx! { h1 { class: "media-content-title", style: "{title_style}", "{title}" } },
146                    2 => rsx! { h2 { class: "media-content-title", style: "{title_style}", "{title}" } },
147                    3 => rsx! { h3 { class: "media-content-title", style: "{title_style}", "{title}" } },
148                    4 => rsx! { h4 { class: "media-content-title", style: "{title_style}", "{title}" } },
149                    5 => rsx! { h5 { class: "media-content-title", style: "{title_style}", "{title}" } },
150                    _ => rsx! { h6 { class: "media-content-title", style: "{title_style}", "{title}" } },
151                }
152            }
153
154            if let Some(meta) = props.meta {
155                span {
156                    class: "media-content-meta",
157                    style: "font-size: 12px; color: {theme.tokens.read().colors.muted.to_rgba()};",
158                    "{meta}"
159                }
160            }
161
162            if let Some(description) = props.description {
163                p {
164                    class: "media-content-description",
165                    style: "margin: 0; font-size: 14px; color: {theme.tokens.read().colors.foreground.to_rgba()}; line-height: 1.6;",
166                    "{description}"
167                }
168            }
169
170            if let Some(children) = props.children {
171                div {
172                    class: "media-content-body",
173                    {children}
174                }
175            }
176
177            if let Some(actions) = props.actions {
178                div {
179                    class: "media-content-actions",
180                    style: "display: flex; gap: 8px; margin-top: 4px;",
181                    {actions}
182                }
183            }
184        }
185    }
186}
187
188/// Comment item properties
189#[derive(Props, Clone, PartialEq)]
190pub struct CommentProps {
191    /// Author name
192    pub author: String,
193    /// Author avatar
194    #[props(default)]
195    pub avatar: Option<Element>,
196    /// Comment text
197    pub content: String,
198    /// Timestamp
199    pub timestamp: String,
200    /// Reply action
201    #[props(default)]
202    pub on_reply: Option<EventHandler<()>>,
203    /// Like action
204    #[props(default)]
205    pub on_like: Option<EventHandler<()>>,
206    /// Whether liked
207    #[props(default = false)]
208    pub liked: bool,
209    /// Like count
210    #[props(default = 0)]
211    pub like_count: u32,
212    /// Nested replies
213    #[props(default)]
214    pub replies: Option<Element>,
215    /// Additional CSS classes
216    #[props(default)]
217    pub class: Option<String>,
218}
219
220/// Comment component (nested discussion)
221#[component]
222pub fn Comment(props: CommentProps) -> Element {
223    let theme = use_theme();
224
225    let class_css = props
226        .class
227        .as_ref()
228        .map(|c| format!(" {}", c))
229        .unwrap_or_default();
230
231    let like_color = if props.liked {
232        "#ef4444".to_string()
233    } else {
234        theme.tokens.read().colors.muted.to_rgba()
235    };
236
237    let avatar_element = props.avatar.unwrap_or_else(|| {
238        rsx! {
239            div {
240                style: "width: 40px; height: 40px; border-radius: 50%; background: {theme.tokens.read().colors.muted.to_rgba()}; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 600; color: {theme.tokens.read().colors.foreground.to_rgba()};",
241                "{props.author.chars().next().unwrap_or('?').to_uppercase()}"
242            }
243        }
244    });
245
246    rsx! {
247        div {
248            class: "comment{class_css}",
249            style: "display: flex; gap: 12px;",
250
251            div {
252                class: "comment-avatar",
253                style: "flex-shrink: 0;",
254                {avatar_element}
255            }
256
257            div {
258                class: "comment-body",
259                style: "flex: 1;",
260
261                div {
262                    class: "comment-header",
263                    style: "display: flex; align-items: center; gap: 8px; margin-bottom: 4px;",
264
265                    span {
266                        class: "comment-author",
267                        style: "font-weight: 600; font-size: 14px; color: {theme.tokens.read().colors.foreground.to_rgba()};",
268                        "{props.author}"
269                    }
270
271                    span {
272                        class: "comment-timestamp",
273                        style: "font-size: 12px; color: {theme.tokens.read().colors.muted.to_rgba()};",
274                        "{props.timestamp}"
275                    }
276                }
277
278                p {
279                    class: "comment-content",
280                    style: "margin: 0 0 8px 0; font-size: 14px; color: {theme.tokens.read().colors.foreground.to_rgba()}; line-height: 1.5;",
281                    "{props.content}"
282                }
283
284                div {
285                    class: "comment-actions",
286                    style: "display: flex; gap: 16px;",
287
288                    if let Some(on_reply) = props.on_reply {
289                        button {
290                            type: "button",
291                            class: "comment-reply",
292                            style: "font-size: 13px; color: {theme.tokens.read().colors.muted.to_rgba()}; background: none; border: none; cursor: pointer;",
293                            onclick: move |_| on_reply.call(()),
294                            "Reply"
295                        }
296                    }
297
298                    if let Some(on_like) = props.on_like {
299                        button {
300                            type: "button",
301                            class: "comment-like",
302                            style: "font-size: 13px; color: {like_color}; background: none; border: none; cursor: pointer; display: flex; align-items: center; gap: 4px;",
303                            onclick: move |_| on_like.call(()),
304
305                            if props.liked {
306                                "❤️"
307                            } else {
308                                "🤍"
309                            }
310
311                            if props.like_count > 0 {
312                                "{props.like_count}"
313                            }
314                        }
315                    }
316                }
317
318                if let Some(replies) = props.replies {
319                    div {
320                        class: "comment-replies",
321                        style: "margin-top: 16px; padding-left: 20px; border-left: 2px solid {theme.tokens.read().colors.border.to_rgba()};",
322                        {replies}
323                    }
324                }
325            }
326        }
327    }
328}