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}