yggdrasil/pages/admin/write/
metadata.rs1use 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
11pub(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 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}