yggdrasil/pages/admin/note_editor.rs
1//! 写笔记与编辑笔记(`/admin/notes/new`、`/admin/notes/edit/:id`)。
2//!
3//! 与写文章同一套无干扰版式:页头(返回、保存状态、查看公开页面、预览、设置、保存)
4//! 替代后台顶栏;类型分段、衬线标题与 Tiptap 正文居中;右侧设置抽屉是版本面板
5//! (工作稿、公开版、AI 知识库)、笔记本、标签与历史版本。工作稿停止输入后自动保存,
6//! 公开与知识库快照只由明确操作更新。样式沿用 styles/pages/admin-write.css,
7//! 笔记专属部分在 admin-notes.css。数据只在浏览器鉴权后读取。
8mod composer;
9mod history;
10mod metadata;
11mod save;
12mod state;
13
14use super::note_drafts::use_draft_protection;
15use crate::api::notes::get_owned_note;
16use crate::components::empty_state::{EmptyState, EmptyStateAction};
17use crate::components::skeletons::write_skeleton::WriteSkeleton;
18use crate::components::ui::{
19 button_class, use_toast, ButtonSize, ButtonVariant, Icon, IconName, LoadError,
20 SegmentedControl, SegmentedItem,
21};
22use crate::models::note::*;
23use crate::routes::Route;
24use crate::utils::error::is_not_found;
25use composer::NoteComposer;
26use leptos::ev::KeyboardEvent;
27use leptos::prelude::*;
28use leptos::task::spawn_local;
29use leptos_router::hooks::use_params_map;
30use leptos_router::{lazy_route, LazyRoute};
31use metadata::MetadataPanel;
32use save::persist;
33use state::SaveStatus;
34
35/// `/admin/notes/new`。
36pub struct NewNoteRoute;
37
38#[lazy_route]
39impl LazyRoute for NewNoteRoute {
40 fn data() -> Self {
41 Self
42 }
43
44 fn view(_this: Self) -> AnyView {
45 view! { <NoteEditor initial=None/> }.into_any()
46 }
47}
48
49/// `/admin/notes/edit/:id`。
50pub struct EditNoteRoute {
51 id: Memo<i32>,
52}
53
54#[lazy_route]
55impl LazyRoute for EditNoteRoute {
56 fn data() -> Self {
57 let params = use_params_map();
58 Self {
59 id: Memo::new(move |_| {
60 params
61 .read()
62 .get("id")
63 .and_then(|id| id.parse().ok())
64 .unwrap_or_default()
65 }),
66 }
67 }
68
69 fn view(this: Self) -> AnyView {
70 let id = this.id;
71 // 换一条笔记就整体重建编辑器,不带走上一条的状态。
72 view! {
73 {move || {
74 let id = id.get();
75 view! { <LoadNoteEditor id=id/> }
76 }}
77 }
78 .into_any()
79 }
80}
81
82/// 加载失败的两种形态:笔记不存在,或其他错误(可重试)。
83#[derive(Clone, Copy, PartialEq)]
84enum LoadFailure {
85 Missing,
86 Failed,
87}
88
89#[component]
90fn LoadNoteEditor(id: i32) -> impl IntoView {
91 // 编辑器位于路由的 `<Suspense>` 内,笔记也会重试加载:用 Effect + 镜像信号,不用 Resource。
92 let response = RwSignal::new(None::<Result<Note, LoadFailure>>);
93 let reload = RwSignal::new(0_u32);
94 let generation = StoredValue::new(0_u64);
95 Effect::new(move |_| {
96 reload.track();
97 let Some(token) = generation.try_update_value(|g| {
98 *g += 1;
99 *g
100 }) else {
101 return;
102 };
103 spawn_local(async move {
104 let result = get_owned_note(id, None).await.map_err(|e| {
105 if is_not_found(&e) {
106 LoadFailure::Missing
107 } else {
108 LoadFailure::Failed
109 }
110 });
111 if generation.try_get_value() == Some(token) {
112 let _ = response.try_set(Some(result));
113 }
114 });
115 });
116
117 move || match response.get() {
118 Some(Ok(note)) => view! { <NoteEditor initial=Some(note)/> }.into_any(),
119 Some(Err(LoadFailure::Missing)) => view! {
120 <div class="write-missing">
121 <EmptyState
122 icon=IconName::FileX
123 title="找不到这条笔记"
124 description="它可能已被彻底删除。"
125 action=EmptyStateAction::new(
126 "返回笔记",
127 move |_| {
128 crate::navigation::push(&Route::AdminNotes.to_string());
129 },
130 )
131 />
132 </div>
133 }
134 .into_any(),
135 Some(Err(LoadFailure::Failed)) => view! {
136 <div class="write-missing">
137 <LoadError
138 title="笔记加载失败"
139 on_retry=Callback::new(move |()| reload.update(|n| *n = n.wrapping_add(1)))
140 />
141 </div>
142 }
143 .into_any(),
144 None => view! { <WriteSkeleton/> }.into_any(),
145 }
146}
147
148#[component]
149fn NoteEditor(initial: Option<Note>) -> impl IntoView {
150 let toast = use_toast();
151 let state = state::use_editor_state(initial, toast);
152 let slot = state
153 .draft
154 .with_untracked(|d| d.id)
155 .map(|id| id.to_string())
156 .unwrap_or_else(|| "new".into());
157 let (recovery, cache_failed) = use_draft_protection(slot, state.draft, move || {
158 state.dirty() || state.busy() || state.uploading.get()
159 });
160 let preview = RwSignal::new(false);
161 let drawer_open = RwSignal::new(true);
162 // 只有点「设置」打开时才播滑入动画;首屏、换页与编辑器挂载时直接出现。
163 let drawer_animate = RwSignal::new(false);
164 // 首屏与 SSR 一致,水合后窄屏默认收起设置抽屉。
165 Effect::new(move |_| {
166 #[cfg(target_arch = "wasm32")]
167 {
168 let wide = web_sys::window()
169 .and_then(|w| w.inner_width().ok())
170 .and_then(|w| w.as_f64())
171 .is_some_and(|w| w >= 1024.0);
172 drawer_open.set(wide);
173 }
174 #[cfg(not(target_arch = "wasm32"))]
175 let _ = drawer_open;
176 });
177 save::use_autosave(state, recovery);
178
179 let save_now = move || {
180 spawn_local(async move {
181 persist(state, None).await;
182 });
183 };
184 let toggle_preview = move |_| {
185 spawn_local(async move {
186 if preview.try_get_untracked() == Some(true) || persist(state, None).await {
187 let _ = preview.try_update(|p| *p = !*p);
188 }
189 });
190 };
191
192 let error = Memo::new(move |_| state.error());
193 let deleted = Memo::new(move |_| {
194 state
195 .saved
196 .with(|n| n.as_ref().is_some_and(|n| n.deleted_at.is_some()))
197 });
198 let recovering = Memo::new(move |_| recovery.with(Option::is_some));
199 let status = Memo::new(move |_| {
200 SaveStatus::of(
201 state.busy(),
202 !error.get().is_empty(),
203 state.dirty(),
204 recovering.get(),
205 state.saved.with(|n| n.as_ref().map(|n| n.version)),
206 )
207 });
208 let failed = Memo::new(move |_| status.get() == SaveStatus::Failed);
209 let public_url = Memo::new(move |_| {
210 state.saved.with(|n| {
211 n.as_ref()
212 .filter(|n| n.published_version.is_some())
213 .map(|n| format!("/notes/{}", n.slug))
214 })
215 });
216 let locked = Memo::new(move |_| state.busy() || deleted.get() || recovering.get());
217 let kind = Memo::new(move |_| state.draft.with(|d| d.kind));
218
219 view! {
220 <div
221 class="write-page note-editor"
222 on:keydown=move |e: KeyboardEvent| {
223 if (e.meta_key() || e.ctrl_key()) && e.key() == "s" {
224 e.prevent_default();
225 if !locked.get_untracked() {
226 save_now();
227 }
228 }
229 }
230 >
231 <header class="write-head">
232 <div class="write-head-left">
233 <a
234 class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
235 href=Route::AdminNotes.to_string()
236 >
237 <Icon name=IconName::ArrowLeft size=16/>
238 <span class="write-hide-narrow">"笔记管理"</span>
239 <span class="sr-only">"返回"</span>
240 </a>
241 </div>
242 <div class="write-head-actions">
243 {move || {
244 if failed.get() {
245 view! {
246 <button
247 class="write-status"
248 type="button"
249 data-tone=move || status.get().tone()
250 data-state=move || status.get().key()
251 title=move || error.get()
252 on:click=move |_| save_now()
253 >
254 <span class="write-status-dot" aria-hidden="true"></span>
255 <span role="status">{move || status.get().label()}</span>
256 </button>
257 }
258 .into_any()
259 } else {
260 view! {
261 <span
262 class="write-status"
263 data-tone=move || status.get().tone()
264 data-state=move || status.get().key()
265 data-pulse=move || (status.get() == SaveStatus::Saving).then_some("true")
266 >
267 <span class="write-status-dot" aria-hidden="true"></span>
268 <span role="status" aria-live="polite">
269 {move || status.get().label()}
270 </span>
271 </span>
272 }
273 .into_any()
274 }
275 }}
276 {move || {
277 public_url
278 .get()
279 .map(|url| {
280 view! {
281 <a
282 class=format!(
283 "{} write-hide-narrow",
284 button_class(ButtonVariant::Ghost, ButtonSize::Md),
285 )
286 href=url
287 target="_blank"
288 rel="noopener"
289 >
290 "查看公开页面"
291 <Icon name=IconName::ArrowUpRight size=14/>
292 </a>
293 }
294 })
295 }}
296 <button
297 class=format!(
298 "{} write-hide-narrow",
299 button_class(ButtonVariant::Ghost, ButtonSize::Md),
300 )
301 type="button"
302 disabled=move || {
303 state.busy() || state.saved.with(Option::is_none) || recovering.get()
304 }
305 aria-pressed=move || preview.get().to_string()
306 on:click=toggle_preview
307 >
308 {move || if preview.get() { "返回编辑" } else { "预览" }}
309 </button>
310 <button
311 class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
312 type="button"
313 aria-pressed=move || drawer_open.get().to_string()
314 aria-controls="note-drawer"
315 // 窄屏只剩图标,名称由 aria-label 补出。
316 aria-label="设置"
317 on:click=move |_| {
318 drawer_animate.set(true);
319 drawer_open.update(|open| *open = !*open);
320 }
321 >
322 <Icon name=IconName::PanelRight size=15/>
323 <span class="write-hide-narrow">"设置"</span>
324 </button>
325 <button
326 class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
327 type="button"
328 disabled=move || locked.get()
329 title="⌘S"
330 on:click=move |_| save_now()
331 >
332 "保存草稿"
333 </button>
334 </div>
335 </header>
336
337 {move || {
338 recovery
339 .get()
340 .map(|local| {
341 view! {
342 <div class="write-banner" role="alert">
343 <Icon name=IconName::RotateCcwClock size=18/>
344 <div>
345 <p class="write-banner-title">"这个标签页留有未保存的内容"</p>
346 <p>
347 "恢复只修改工作稿,不会发布;若服务端已有新版本,请先核对内容。选择之前编辑器暂停输入。"
348 </p>
349 <div class="write-banner-actions">
350 <button
351 class=button_class(ButtonVariant::Primary, ButtonSize::Sm)
352 type="button"
353 on:click=move |_| {
354 state.restore_local(local.clone());
355 recovery.set(None);
356 }
357 >
358 "恢复未保存内容"
359 </button>
360 <button
361 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
362 type="button"
363 on:click=move |_| recovery.set(None)
364 >
365 "使用服务端版本"
366 </button>
367 </div>
368 </div>
369 </div>
370 }
371 })
372 }}
373
374 <div class="write-body">
375 <main class="write-main" data-vt-scroll="admin-note-content">
376 <div class="write-doc">
377 {move || {
378 let message = error.get();
379 (!message.is_empty())
380 .then(|| {
381 view! {
382 <p class="write-notice" role="alert">
383 <span>{message}</span>
384 </p>
385 }
386 })
387 }}
388 {move || {
389 cache_failed
390 .get()
391 .then(|| {
392 view! {
393 <p class="write-notice" role="alert">
394 "浏览器无法保存本地恢复副本,请先保存到服务端再离开。"
395 </p>
396 }
397 })
398 }}
399 {move || {
400 deleted
401 .get()
402 .then(|| {
403 view! {
404 <div class="note-trashed" role="status">
405 <Icon name=IconName::Trash size=16/>
406 <p>
407 "这条笔记在回收站中。恢复后可继续编辑,公开与知识库收录需要重新确认。"
408 </p>
409 <button
410 class=button_class(ButtonVariant::Primary, ButtonSize::Sm)
411 type="button"
412 disabled=move || state.busy()
413 on:click=move |_| {
414 spawn_local(async move {
415 persist(state, Some(NoteAction::Restore)).await;
416 });
417 }
418 >
419 "恢复笔记"
420 </button>
421 </div>
422 }
423 })
424 }}
425 {move || {
426 if preview.get() {
427 // 预览复用公开渲染路径:完整 Note 转为 PublicNote(编辑器的工作稿
428 // 版本号保留展示),显示字段不变,Markdown 不会进入公开渲染组件。
429 view! {
430 {move || {
431 state
432 .saved
433 .get()
434 .map(|note| {
435 view! {
436 <crate::pages::notes::NoteDocument
437 note=crate::models::public_note::PublicNote::from(note)
438 preview=true
439 />
440 }
441 })
442 }}
443 }
444 .into_any()
445 } else {
446 view! {
447 <SegmentedControl
448 class="note-kind-tabs"
449 aria_label="笔记类型"
450 items=vec![
451 SegmentedItem::new("moment".to_string(), "随记"),
452 SegmentedItem::new("topic".to_string(), "主题笔记"),
453 ]
454 value=Signal::derive(move || kind.get().as_str().to_string())
455 on_change=Callback::new(move |value: String| {
456 if !locked.get_untracked() {
457 state
458 .draft
459 .update(|d| {
460 d.kind = if value == "topic" {
461 NoteKind::Topic
462 } else {
463 NoteKind::Moment
464 };
465 });
466 }
467 })
468 />
469 <label class="sr-only" for="note-title">
470 "笔记标题"
471 </label>
472 <textarea
473 id="note-title"
474 class="write-title note-title"
475 rows="1"
476 maxlength="200"
477 readonly=move || recovering.get() || deleted.get()
478 placeholder=move || {
479 if kind.get() == NoteKind::Moment {
480 "标题可选,先记下来…"
481 } else {
482 "给这篇笔记一个标题"
483 }
484 }
485 prop:value=move || state.draft.with(|d| d.title.clone())
486 on:input=move |ev| {
487 let value = event_target_value(&ev).replace(['\n', '\r'], "");
488 state.draft.update(|d| d.title = value);
489 }
490 on:keydown=move |e: KeyboardEvent| {
491 if e.key() == "Enter" && !e.is_composing() {
492 e.prevent_default();
493 composer::focus_editor();
494 }
495 }
496 >
497 {state.draft.with_untracked(|d| d.title.clone())}
498 </textarea>
499 <div
500 class="write-editor-wrap"
501 inert=move || (recovering.get() || deleted.get()).then_some("true")
502 >
503 {move || {
504 let _ = state.editor_epoch.get();
505 let initial = state.draft.with_untracked(|d| d.content_md.clone());
506 view! {
507 <NoteComposer
508 initial=initial
509 on_change=Callback::new(move |md: String| {
510 state.draft.update(|d| d.content_md = md)
511 })
512 uploading=state.uploading
513 />
514 }
515 }}
516 </div>
517 }
518 .into_any()
519 }
520 }}
521 </div>
522 </main>
523 {move || {
524 drawer_open
525 .get()
526 .then(|| {
527 view! {
528 <div
529 id="note-drawer"
530 class="write-drawer-slot"
531 data-enter=move || drawer_animate.get().then_some("true")
532 >
533 <MetadataPanel state open=drawer_open locked/>
534 </div>
535 }
536 })
537 }}
538 </div>
539 </div>
540 }
541}