Skip to main content

yggdrasil/pages/admin/write/
metadata.rs

1//! 文章属性抽屉:永久链接、标签、摘要、封面与字数;与正文编辑器共享表单。
2use super::{
3    cover::CoverUploader,
4    state::{PostDraft, WriteEditorState},
5};
6use crate::components::forms::FormTextarea;
7use crate::components::post::post_meta::group_digits;
8use crate::components::ui::{ButtonSize, Icon, IconButton, IconName};
9use leptos::prelude::*;
10
11/// 正文字数(不计空白)与约略阅读分钟数。
12pub(super) fn word_stats(markdown: &str) -> (usize, usize) {
13    let chars = markdown.chars().filter(|c| !c.is_whitespace()).count();
14    (chars, (chars / 300).max(1))
15}
16
17#[component]
18pub(super) fn MetadataPanel(
19    state: WriteEditorState,
20    open: RwSignal<bool>,
21    #[prop(into)] on_trash: Callback<()>,
22) -> impl IntoView {
23    let form = state.form;
24    let tag_input = RwSignal::new(String::new());
25    let tags = Memo::new(move |_| form.with(PostDraft::parsed_tags));
26    let summary_len = Memo::new(move |_| form.with(|f| f.summary.chars().count()));
27    let stats = Memo::new(move |_| form.with(|f| word_stats(&f.content)));
28    let commit_tag = move || {
29        let pending = tag_input.get_untracked();
30        if !pending.trim().is_empty() {
31            form.update(|f| f.add_tags(&pending));
32        }
33        tag_input.set(String::new());
34    };
35
36    view! {
37        <aside class="write-drawer" data-vt-scroll="admin-write-settings" aria-label="文章属性">
38            <div class="write-drawer-head">
39                <h2>"文章属性"</h2>
40                <IconButton
41                    icon=IconName::X
42                    label="收起属性"
43                    size=ButtonSize::Sm
44                    tooltip_align="end"
45                    onclick=move |_| open.set(false)
46                />
47            </div>
48
49            <div class="write-prop">
50                <label class="write-prop-label" for="write-slug">
51                    <span>"永久链接"</span>
52                    <span class="write-prop-hint">"留空自动生成"</span>
53                </label>
54                <div class="field write-slug">
55                    <span aria-hidden="true">"/post/"</span>
56                    <input
57                        id="write-slug"
58                        type="text"
59                        spellcheck="false"
60                        autocomplete="off"
61                        value=form.with_untracked(|f| f.slug.clone())
62                        prop:value=move || form.with(|f| f.slug.clone())
63                        on:input=move |e| {
64                            let value = event_target_value(&e);
65                            form.update(|f| f.slug = value);
66                        }
67                    />
68                </div>
69            </div>
70
71            <div class="write-prop">
72                <label class="write-prop-label" for="write-tag-input">
73                    <span>"标签"</span>
74                    <span class="write-prop-hint">"回车或逗号添加"</span>
75                </label>
76                <div class="field write-tags">
77                    <For each=move || tags.get() key=|tag| tag.clone() let:tag>
78                        <span class="write-tag">
79                            {tag.clone()}
80                            <button
81                                type="button"
82                                aria-label=format!("移除标签 {tag}")
83                                on:click={
84                                    let tag = tag.clone();
85                                    move |_| form.update(|f| f.remove_tag(&tag))
86                                }
87                            >
88                                <Icon name=IconName::X size=11/>
89                            </button>
90                        </span>
91                    </For>
92                    <input
93                        id="write-tag-input"
94                        type="text"
95                        placeholder=move || if tags.with(Vec::is_empty) { "添加标签…" } else { "" }
96                        prop:value=move || tag_input.get()
97                        on:input=move |e| {
98                            let value = event_target_value(&e);
99                            // 输入或粘贴里带分隔符时直接拆成标签。
100                            let split = value.contains([',', ',', ';', ';']);
101                            tag_input.set(value);
102                            if split {
103                                commit_tag();
104                            }
105                        }
106                        on:keydown=move |e| {
107                            if e.is_composing() {
108                                return;
109                            }
110                            if e.key() == "Enter" {
111                                e.prevent_default();
112                                commit_tag();
113                            } else if e.key() == "Backspace" && tag_input.with_untracked(String::is_empty) {
114                                if let Some(last) = tags.with_untracked(|t| t.last().cloned()) {
115                                    form.update(|f| f.remove_tag(&last));
116                                }
117                            }
118                        }
119                        on:blur=move |_| commit_tag()
120                    />
121                </div>
122            </div>
123
124            <div class="write-prop">
125                <label class="write-prop-label" for="write-summary">
126                    <span>"摘要"</span>
127                    <span class="write-prop-hint tabular-nums">{move || summary_len.get()} " 字"</span>
128                </label>
129                <FormTextarea
130                    id="write-summary"
131                    class="field field-textarea write-summary"
132                    rows=5u32
133                    placeholder="选填,留空将从正文提取"
134                    value=Signal::derive(move || form.with(|f| f.summary.clone()))
135                    oninput=move |v: String| form.update(|f| f.summary = v)
136                />
137            </div>
138
139            <div class="write-prop">
140                <div class="write-prop-label">
141                    <span>"封面"</span>
142                    <span class="write-prop-hint">"推荐 21:9"</span>
143                </div>
144                <CoverUploader form cover_uploading=state.cover_uploading/>
145            </div>
146
147            <div class="write-drawer-foot">
148                <span class="tabular-nums">
149                    {move || {
150                        let (words, minutes) = stats.get();
151                        format!("{} 字 · 约 {minutes} 分钟", group_digits(words as u32))
152                    }}
153                </span>
154                {state
155                    .post_id
156                    .is_some()
157                    .then(|| {
158                        view! {
159                            <button class="write-trash" type="button" on:click=move |_| on_trash.run(())>
160                                "移到回收站"
161                            </button>
162                        }
163                    })}
164            </div>
165        </aside>
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::word_stats;
172
173    #[test]
174    fn word_stats_ignore_whitespace_and_round_up_to_one_minute() {
175        assert_eq!(word_stats("你好 世界\n"), (4, 1));
176        assert_eq!(word_stats(&"字".repeat(900)), (900, 3));
177    }
178}