Skip to main content

yggdrasil/pages/components_showcase/
layout_article_previews.rs

1//! 文章组件的本地样例。所有展示复用正式组件,演示操作仅改本地状态。
2
3use leptos::prelude::*;
4
5use crate::components::post::post_content::PostContent;
6use crate::components::post::post_footer::PostFooter;
7use crate::components::post::post_header::PostHeader;
8use crate::components::post::post_toc::PostToc;
9use crate::models::post::{PostFooterData, PostHeaderData, PostNav, PostStatus};
10
11pub(super) fn preview(slug: &str, detail: bool) -> Option<AnyView> {
12    match slug {
13        "post-header" => Some(view! { <PostHeaderPreview detail/> }.into_any()),
14        "post-content" => Some(view! { <PostContentPreview detail/> }.into_any()),
15        "post-footer" => Some(view! { <PostFooterPreview detail/> }.into_any()),
16        "post-toc" => Some(view! { <PostTocPreview detail/> }.into_any()),
17        _ => None,
18    }
19}
20
21#[component]
22fn PostHeaderPreview(detail: bool) -> impl IntoView {
23    let mut post = super::sample_post();
24    post.summary = Some(
25        "在时间的缝隙里,种下一些文字。记录设计、代码与日常,让零散的念头慢慢长成一片风景。"
26            .to_string(),
27    );
28    let mut draft = post.clone();
29    draft.title = "还在生长的想法".to_string();
30    draft.summary = Some("这一页,留给尚未完成的念头。".to_string());
31    draft.status = PostStatus::Draft;
32    draft.published_at = None;
33    let mut without_summary = post.clone();
34    without_summary.title = "在时间的缝隙里种下一些文字:从零开始构建自己的数字花园".to_string();
35    without_summary.summary = None;
36    without_summary.tags = Vec::new();
37
38    let examples = detail.then(|| {
39        view! {
40            <div class="showcase-post-header-examples">
41                <section class="showcase-post-header-example">
42                    <h3>"草稿" <span>"标题旁显示草稿标记"</span></h3>
43                    <article
44                        class="showcase-post-header-sheet"
45                        data-showcase-sample="draft"
46                        inert="true"
47                    >
48                        <PostHeader post=PostHeaderData::from(&draft)/>
49                    </article>
50                </section>
51                <section class="showcase-post-header-example">
52                    <h3>"长标题 · 无摘要 · 无标签" <span>"面包屑只剩「文章」"</span></h3>
53                    <article
54                        class="showcase-post-header-sheet"
55                        data-showcase-sample="no-summary"
56                        inert="true"
57                    >
58                        <PostHeader post=PostHeaderData::from(&without_summary)/>
59                    </article>
60                </section>
61            </div>
62        }
63    });
64
65    view! {
66        <div class="showcase-post-header-demo" data-showcase-preview="post-header">
67            <article
68                class="showcase-post-header-sheet"
69                data-showcase-sample="published"
70                inert="true"
71            >
72                <PostHeader post=PostHeaderData::from(&post)/>
73            </article>
74            {examples}
75        </div>
76    }
77}
78
79fn article_html(prefix: &str, alternate: bool) -> String {
80    let opening = if alternate {
81        "雨停之后,树叶留下细小的光。"
82    } else {
83        "一片叶子,也记得来时的风。"
84    };
85    format!(
86        r#"<h2 id="{prefix}-opening">从一片叶子开始</h2>
87<p>{opening} 这段固定文章展示正文排版和图片,不连接任何服务。</p>
88<h3 id="{prefix}-details">沿途所见</h3>
89<ul><li>清晨的光</li><li>缓慢生长的枝条</li></ul>
90<blockquote><p>让想法,像树一样生长。</p></blockquote>
91<pre><code class="language-rust">let leaf = "Yggdrasil";
92println!("{{leaf}}");</code></pre>
93<h2 id="{prefix}-image">留下一张照片</h2>
94<p><img src="/images/samples/clay-circle.webp" alt="暖白纸面上的一轮陶土色圆" loading="lazy"></p>"#
95    )
96}
97
98fn toc_html(prefix: &str) -> String {
99    format!(
100        r##"<ul><li><a href="#{prefix}-opening">从一片叶子开始</a><ul><li><a href="#{prefix}-details">沿途所见</a></li></ul></li><li><a href="#{prefix}-image">留下一张照片</a></li></ul>"##
101    )
102}
103
104#[component]
105fn PostContentPreview(detail: bool) -> impl IntoView {
106    let alternate = RwSignal::new(false);
107    let (scope_id, heading_prefix) = if detail {
108        (
109            "showcase-post-content-detail",
110            "showcase-post-content-detail",
111        )
112    } else {
113        ("showcase-post-content-card", "showcase-post-content-card")
114    };
115    let toggle = detail.then(|| {
116        view! {
117            <button
118                class="mb-4 text-sm text-clay-text"
119                type="button"
120                on:click=move |_| alternate.update(|value| *value = !*value)
121            >
122                "切换文章样例"
123            </button>
124        }
125    });
126
127    view! {
128        <div class="showcase-article-window" data-showcase-preview="post-content">
129            {toggle}
130            // 切换样例时整段重建,等价于 BASE 里按 variant 做 key 的重新挂载。
131            {move || {
132                let variant = alternate.get();
133                view! {
134                    <PostContent
135                        content_html=article_html(heading_prefix, variant)
136                        scope_id=scope_id
137                    />
138                }
139            }}
140        </div>
141    }
142}
143
144#[component]
145fn PostFooterPreview(detail: bool) -> impl IntoView {
146    let prev = PostNav {
147        title: "沿着风的方向".to_string(),
148        slug: "sample-previous".to_string(),
149    };
150    let next = PostNav {
151        title: "下一圈年轮".to_string(),
152        slug: "sample-next".to_string(),
153    };
154    let footer = |prev_post: Option<PostNav>, next_post: Option<PostNav>| PostFooterData {
155        prev_post,
156        next_post,
157    };
158    let examples = detail.then(|| {
159        view! {
160            <div class="showcase-post-nav-examples">
161                <section class="showcase-post-nav-example" data-showcase-sample="next-only">
162                    <h3>"第一篇文章" <span>"只有下一篇"</span></h3>
163                    <PostFooter post=footer(None, Some(next.clone())) sample=true/>
164                </section>
165                <section class="showcase-post-nav-example" data-showcase-sample="prev-only">
166                    <h3>"最后一篇文章" <span>"只有上一篇"</span></h3>
167                    <PostFooter post=footer(Some(prev.clone()), None) sample=true/>
168                </section>
169                <section class="showcase-post-nav-empty" data-showcase-sample="empty">
170                    <PostFooter post=footer(None, None) sample=true/>
171                    <p>"没有相邻文章时,整个页脚不渲染。"</p>
172                </section>
173            </div>
174            <p class="showcase-post-footer-note">"试试悬停或用 Tab 聚焦卡片。样例链接停留在本页。"</p>
175        }
176    });
177
178    view! {
179        <div class="showcase-article-window" data-showcase-preview="post-footer">
180            <div data-showcase-sample="both">
181                <PostFooter post=footer(Some(prev.clone()), Some(next.clone())) sample=true/>
182            </div>
183            {examples}
184        </div>
185    }
186}
187
188#[component]
189fn PostTocPreview(detail: bool) -> impl IntoView {
190    let (scroll_id, nav_id, content_id) = if detail {
191        (
192            "showcase-post-toc-scroll-detail",
193            "showcase-post-toc-nav-detail",
194            "showcase-post-toc-content-detail",
195        )
196    } else {
197        (
198            "showcase-post-toc-scroll-card",
199            "showcase-post-toc-nav-card",
200            "showcase-post-toc-content-card",
201        )
202    };
203
204    view! {
205        <div
206            id=scroll_id
207            class="showcase-toc-window showcase-article-window"
208            data-showcase-preview="post-toc"
209            data-local-anchor-scroll="true"
210        >
211            <PostToc
212                toc_html=toc_html(content_id)
213                title="文章目录"
214                nav_id=nav_id
215                content_id=content_id
216                scroll_id=scroll_id
217            />
218            <PostContent content_html=article_html(content_id, false) scope_id=content_id/>
219        </div>
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn article_and_toc_share_scoped_headings() {
229        let html = article_html("sample", false);
230        let toc = toc_html("sample");
231        for id in ["sample-opening", "sample-details", "sample-image"] {
232            assert!(html.contains(&format!("id=\"{id}\"")));
233            assert!(toc.contains(&format!("href=\"#{id}\"")));
234        }
235        assert!(!html.contains("data-runnable"));
236    }
237
238    #[test]
239    fn alternate_article_changes_real_content() {
240        assert_ne!(article_html("sample", false), article_html("sample", true));
241    }
242}