Skip to main content

yggdrasil/pages/admin/note_editor/
metadata.rs

1use super::{history::HistoryPanel, save::persist, state::EditorState};
2use crate::api::notes::owned_notebooks;
3use crate::components::forms::FormInput;
4use crate::components::ui::{
5    button_class, ButtonSize, ButtonVariant, ConfirmDialog, Icon, IconButton, IconName, StatusDot,
6};
7use crate::models::note::*;
8use crate::pages::admin::notes::parse_tags;
9use crate::routes::Route;
10use leptos::prelude::*;
11use leptos::task::spawn_local;
12
13/// 落后几个版本的说明;`None` 表示与工作稿一致。
14fn behind(work: i32, snapshot: i32) -> Option<i32> {
15    (work > snapshot).then_some(work - snapshot)
16}
17
18/// 设置抽屉:版本面板、笔记本、标签、历史版本与移入回收站。
19#[component]
20pub(super) fn MetadataPanel(
21    state: EditorState,
22    open: RwSignal<bool>,
23    #[prop(into)] locked: Signal<bool>,
24) -> impl IntoView {
25    // 位于路由的 `<Suspense>` 内:不用 Resource,Effect 取一次写镜像信号。
26    let books = RwSignal::new(None::<Result<Vec<Notebook>, ()>>);
27    Effect::new(move |_| {
28        spawn_local(async move {
29            let result = owned_notebooks().await.map_err(|_| ());
30            let _ = books.try_set(Some(result));
31        });
32    });
33    let tags = state.tags;
34    let confirm_trash = RwSignal::new(false);
35
36    let dirty = Memo::new(move |_| state.dirty());
37    let work = Memo::new(move |_| state.saved.with(|n| n.as_ref().map(|n| n.version)));
38    let published = Memo::new(move |_| {
39        state
40            .saved
41            .with(|n| n.as_ref().and_then(|n| n.published_version))
42    });
43    let knowledge = Memo::new(move |_| {
44        state
45            .saved
46            .with(|n| n.as_ref().and_then(|n| n.knowledge_version))
47    });
48    let public_tone = move || {
49        if published.get().is_some() {
50            "clay"
51        } else {
52            "muted"
53        }
54    };
55    // 有未保存修改时,操作会先保存出新版本,所以一致也允许点。
56    let publish_same =
57        Memo::new(move |_| !dirty.get() && work.get().is_some() && published.get() == work.get());
58    let knowledge_same =
59        Memo::new(move |_| !dirty.get() && work.get().is_some() && knowledge.get() == work.get());
60    let selected_ids = Memo::new(move |_| state.draft.with(|d| d.notebook_ids.clone()));
61    let can_trash = Memo::new(move |_| {
62        state
63            .saved
64            .with(|n| n.as_ref().is_some_and(|n| n.deleted_at.is_none()))
65    });
66    let act = move |action: NoteAction| {
67        spawn_local(async move {
68            persist(state, Some(action)).await;
69        });
70    };
71
72    view! {
73        <aside class="write-drawer note-drawer" aria-label="笔记设置">
74            <div class="write-drawer-head">
75                <h2>"设置"</h2>
76                <IconButton
77                    icon=IconName::X
78                    label="关闭设置"
79                    size=ButtonSize::Sm
80                    onclick=move |_| open.set(false)
81                />
82            </div>
83
84            <section class="note-side-section" aria-labelledby="note-versions-h">
85                <h2 id="note-versions-h" class="note-side-title">
86                    "版本"
87                </h2>
88                <ol class="note-versions">
89                    <li>
90                        <div class="note-version-head">
91                            <span>"工作稿"</span>
92                            {move || {
93                                work
94                                    .get()
95                                    .map(|v| {
96                                        view! { <span class="note-version-num">{format!("v{v}")}</span> }
97                                    })
98                            }}
99                        </div>
100                        <p>"只有你能看到。停止输入约 1.2 秒后自动保存。"</p>
101                    </li>
102                    <li data-kind="public">
103                        <div class="note-version-head">
104                            <StatusDot label="公开版" tone=Signal::derive(move || public_tone().to_string())/>
105                            {move || {
106                                published
107                                    .get()
108                                    .map(|v| {
109                                        view! { <span class="note-version-num">{format!("v{v}")}</span> }
110                                    })
111                            }}
112                        </div>
113                        {move || match (published.get(), work.get()) {
114                            (None, _) => {
115                                view! { <p>"仅自己可见。发布后访客可以在笔记页读到这一版。"</p> }
116                                    .into_any()
117                            }
118                            (Some(p), Some(w)) if behind(w, p).is_some() => {
119                                view! {
120                                    <p class="note-version-ahead">
121                                        {format!("工作稿比公开版新 {} 个版本,修改不会自动发布。", w - p)}
122                                    </p>
123                                }
124                                    .into_any()
125                            }
126                            _ => view! { <p>"与工作稿一致。"</p> }.into_any(),
127                        }}
128                        <div class="note-version-actions">
129                            <button
130                                class=button_class(ButtonVariant::Primary, ButtonSize::Sm)
131                                type="button"
132                                disabled=move || locked.get() || publish_same.get()
133                                on:click=move |_| act(NoteAction::Publish)
134                            >
135                                {move || if published.get().is_some() { "更新公开版" } else { "发布到前台" }}
136                            </button>
137                            {move || {
138                                published
139                                    .get()
140                                    .is_some()
141                                    .then(|| {
142                                        view! {
143                                            <button
144                                                class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
145                                                type="button"
146                                                disabled=move || locked.get()
147                                                on:click=move |_| act(NoteAction::Unpublish)
148                                            >
149                                                "撤回"
150                                            </button>
151                                        }
152                                    })
153                            }}
154                        </div>
155                    </li>
156                    <li data-kind="knowledge">
157                        <div class="note-version-head">
158                            <span>"AI 知识库"</span>
159                            {move || match knowledge.get() {
160                                Some(v) => {
161                                    view! { <span class="note-version-num">{format!("v{v}")}</span> }
162                                        .into_any()
163                                }
164                                None => {
165                                    view! { <span class="note-version-num">"未收录"</span> }.into_any()
166                                }
167                            }}
168                        </div>
169                        <p>"收录后,获得授权的 AI 可以读取这一版内容,无需公开发布。"</p>
170                        {move || {
171                            knowledge
172                                .get()
173                                .zip(work.get())
174                                .and_then(|(k, w)| behind(w, k))
175                                .map(|n| {
176                                    view! {
177                                        <p class="note-version-ahead">
178                                            {format!("已收录的是 {n} 个版本之前的内容。")}
179                                        </p>
180                                    }
181                                })
182                        }}
183                        <div class="note-version-actions">
184                            <button
185                                class=button_class(ButtonVariant::Secondary, ButtonSize::Sm)
186                                type="button"
187                                disabled=move || locked.get() || knowledge_same.get()
188                                on:click=move |_| act(NoteAction::IncludeKnowledge)
189                            >
190                                "收录当前版本"
191                            </button>
192                            {move || {
193                                knowledge
194                                    .get()
195                                    .is_some()
196                                    .then(|| {
197                                        view! {
198                                            <button
199                                                class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
200                                                type="button"
201                                                disabled=move || locked.get()
202                                                on:click=move |_| act(NoteAction::ExcludeKnowledge)
203                                            >
204                                                "移出"
205                                            </button>
206                                        }
207                                    })
208                            }}
209                        </div>
210                    </li>
211                </ol>
212            </section>
213
214            <section class="note-side-section" aria-labelledby="note-books-h">
215                <h2 id="note-books-h" class="note-side-title">
216                    "笔记本"
217                </h2>
218                {move || match books.get() {
219                    Some(Ok(items)) if items.is_empty() => {
220                        view! {
221                            <p class="note-side-hint">
222                                "还没有笔记本。"
223                                <a href=Route::AdminNotebooks.to_string()>"新建一个"</a>
224                            </p>
225                        }
226                            .into_any()
227                    }
228                    Some(Ok(items)) => {
229                        // 已归档的笔记本只在当前笔记已收进去时显示。
230                        view! {
231                            <div class="note-books" role="group" aria-label="收录到笔记本">
232                                <For
233                                    each=move || {
234                                        let ids = selected_ids.get();
235                                        items
236                                            .iter()
237                                            .filter(|b| b.archived_at.is_none() || ids.contains(&b.id))
238                                            .cloned()
239                                            .collect::<Vec<_>>()
240                                    }
241                                    key=|book| (book.id, book.title.clone(), book.archived_at.is_some())
242                                    children=move |book| {
243                                        let id = book.id;
244                                        let selected = Memo::new(move |_| {
245                                            selected_ids.with(|ids| ids.contains(&id))
246                                        });
247                                        view! {
248                                            <button
249                                                class="note-book"
250                                                type="button"
251                                                aria-pressed=move || selected.get().to_string()
252                                                disabled=move || locked.get()
253                                                on:click=move |_| {
254                                                    state
255                                                        .draft
256                                                        .update(|d| {
257                                                            if let Some(at) = d.notebook_ids.iter().position(|b| *b == id) {
258                                                                d.notebook_ids.remove(at);
259                                                            } else {
260                                                                d.notebook_ids.push(id);
261                                                                d.notebook_ids.sort_unstable();
262                                                            }
263                                                        })
264                                                }
265                                            >
266                                                {move || {
267                                                    selected
268                                                        .get()
269                                                        .then(|| view! { <Icon name=IconName::Check size=12/> })
270                                                }}
271                                                {book.title.clone()}
272                                                {book
273                                                    .archived_at
274                                                    .is_some()
275                                                    .then(|| view! { <span class="note-book-archived">"已归档"</span> })}
276                                            </button>
277                                        }
278                                    }
279                                />
280                            </div>
281                        }
282                            .into_any()
283                    }
284                    Some(Err(())) => {
285                        view! {
286                            <p class="note-side-hint" role="alert">
287                                "笔记本加载失败。"
288                            </p>
289                        }
290                            .into_any()
291                    }
292                    None => {
293                        view! {
294                            <p class="note-side-hint" role="status">
295                                "正在读取笔记本…"
296                            </p>
297                        }
298                            .into_any()
299                    }
300                }}
301            </section>
302
303            <section class="note-side-section">
304                <label class="note-side-title" for="note-tags">
305                    "标签"
306                </label>
307                <FormInput
308                    id="note-tags"
309                    placeholder="Rust, 排错, 读书"
310                    disabled=locked
311                    value=tags
312                    oninput=Callback::new(move |value: String| {
313                        state.draft.update(|d| d.tags = parse_tags(&value));
314                        tags.set(value);
315                    })
316                />
317            </section>
318
319            <HistoryPanel state locked/>
320
321            {move || {
322                can_trash
323                    .get()
324                    .then(|| {
325                        view! {
326                            <div class="write-drawer-foot note-drawer-foot">
327                                <div>
328                                    <button
329                                        class="write-trash"
330                                        type="button"
331                                        disabled=move || locked.get()
332                                        on:click=move |_| confirm_trash.set(true)
333                                    >
334                                        "移入回收站"
335                                    </button>
336                                    <p>"会撤回公开版并移出知识库,之后可以恢复。"</p>
337                                </div>
338                            </div>
339                            <ConfirmDialog
340                                visible=confirm_trash
341                                title="把这条笔记移入回收站?"
342                                description="公开版会撤回,AI 知识库也不再收录。之后可以在回收站恢复为私密草稿。"
343                                confirm_label="移入回收站"
344                                on_confirm=Callback::new(move |()| {
345                                    spawn_local(async move {
346                                        if persist(state, Some(NoteAction::Trash)).await {
347                                            crate::navigation::push(&Route::AdminNotes.to_string());
348                                        }
349                                    });
350                                })
351                            />
352                        }
353                    })
354            }}
355        </aside>
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::behind;
362
363    #[test]
364    fn behind_counts_versions_only_when_snapshot_is_older() {
365        assert_eq!(behind(12, 9), Some(3));
366        assert_eq!(behind(9, 9), None);
367    }
368}