Skip to main content

yggdrasil/components/comments/
card.rs

1//! 评论卡片外壳组件
2//!
3//! `CommentItem`(已审核)与 `PendingCommentItem`(待审核)曾各自手抄几乎相同的
4//! 卡片骨架:深度缩进(孤儿评论归零、`margin-left` 阶梯、子评论左边框)、
5//! 头像 + 内容两栏布局、作者名/时间头部行。两处独立维护一度导致悄然漂移
6//! (已审核评论的作者链接一度缺失文本子节点,待审核评论的对应实现却是对的)。
7//! 本组件抽取该共享骨架,两个调用方只负责各自的差异部分(作者徽章、状态徽章、
8//! 时间展示、正文 HTML、正文下方的附加区域)。
9
10use dioxus::prelude::*;
11
12use crate::components::ui::UserAvatar;
13
14/// 评论卡片外壳组件。
15///
16/// Props:
17/// - `depth`:归一化后的嵌套深度(孤儿评论已由调用方按 0 处理)
18/// - `muted`:是否降低整体透明度展示(待审核评论用,缺省 `false`)
19/// - `avatar_url` / `author_name`:头像 URL 与无障碍 alt 文本
20/// - `author_element`:作者名展示节点(链接或纯文本,由调用方根据 `author_url` 构建)
21/// - `author_badge`:作者名与分隔符之间的徽章(如已审核评论的"作者"标签);
22///   无需展示时传 `rsx! {}`
23/// - `timestamp`:时间展示节点(由调用方构建,两种评论的时间语义不同:已审核评论
24///   展示格式化后的绝对时间,待审核评论展示实时计算的相对时间)
25/// - `status_badge`:时间之后的状态徽章(如待审核评论的"审核中"标签);
26///   无需展示时传 `rsx! {}`
27/// - `content_html`:已渲染为安全 HTML 的评论正文
28/// - `content_extra_class`:正文容器附加 CSS 类(已审核评论需额外挂 `md-content`
29///   以命中全局 Markdown 排版/代码高亮 CSS 作用域,缺省空串)
30/// - `children`:正文下方的附加区域(如已审核评论的回复按钮与回复表单);
31///   无需展示时传 `rsx! {}`
32#[component]
33pub fn CommentCardShell(
34    depth: i32,
35    #[props(default)] muted: bool,
36    avatar_url: String,
37    author_name: String,
38    author_element: Element,
39    author_badge: Element,
40    timestamp: Element,
41    status_badge: Element,
42    content_html: String,
43    #[props(default)] content_extra_class: &'static str,
44    children: Element,
45) -> Element {
46    let is_child = depth > 0;
47
48    rsx! {
49        div {
50            class: "py-3.5 transition-colors",
51            class: if muted { "opacity-75 transition-opacity" } else { "" },
52            class: if is_child { "border-l-2 border-[var(--color-paper-border)]/50 pl-3.5 sm:pl-4.5 my-1" } else { "" },
53            style: if depth > 1 { format!("margin-left: {}px;", (depth.min(5) - 1) * 16) } else { String::new() },
54
55            div { class: "flex items-start gap-3",
56                UserAvatar {
57                    name: author_name,
58                    avatar_url: Some(avatar_url),
59                    class: "w-8 h-8 rounded-full shrink-0 object-cover ring-1 ring-[var(--color-paper-border)]/60 bg-[var(--color-paper-entry)] mt-0.5",
60                }
61
62                div { class: "flex-1 min-w-0",
63                    div { class: "flex items-center gap-2 text-xs mb-1 flex-wrap",
64                        {author_element}
65                        {author_badge}
66                        span { class: "text-paper-tertiary", "·" }
67                        {timestamp}
68                        {status_badge}
69                    }
70
71                    div {
72                        class: if content_extra_class.is_empty() {
73                            "prose prose-sm dark:prose-invert max-w-none text-paper-secondary leading-relaxed".to_string()
74                        } else {
75                            format!("prose prose-sm dark:prose-invert max-w-none text-paper-secondary {content_extra_class} leading-relaxed")
76                        },
77                        dangerous_inner_html: "{content_html}",
78                    }
79
80                    {children}
81                }
82            }
83        }
84    }
85}