Skip to main content

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}