Skip to main content

yggdrasil/pages/admin/
write.rs

1//! 写文章与编辑文章(`/admin/write`、`/admin/write/:id`)。
2//!
3//! 无干扰编辑:页头(返回、面包屑、保存状态、预览、属性、保存按钮)替代后台顶栏;
4//! 衬线标题、元信息行与 Tiptap 正文居中,文章属性在右侧抽屉。保存后留在本页,
5//! 新文章首次保存后换到编辑路由。未保存的修改在同一标签页留有本地副本,离开前确认。
6//! 新文章正文为空时给出起步模板。编辑器通过 [`crate::bridges::tiptap`] 在 WASM 前端
7//! 初始化。样式见 styles/pages/admin-write.css。
8
9use chrono::{DateTime, Utc};
10use leptos::prelude::*;
11use leptos_router::hooks::use_params_map;
12use leptos_router::{lazy_route, LazyRoute};
13
14mod cover;
15mod editor;
16mod metadata;
17mod save;
18mod state;
19
20use super::note_drafts::use_draft_protection;
21use crate::api::posts::get_post_by_id;
22use crate::bridges::library::LibraryLoadError;
23use crate::components::assets::{AssetPickerModal, AssetSelection};
24use crate::components::empty_state::{EmptyState, EmptyStateAction};
25use crate::components::skeletons::write_skeleton::WriteSkeleton;
26use crate::components::ui::{
27    button_class, use_toast, ButtonSize, ButtonVariant, Icon, IconButton, IconName, LoadError,
28    Menu, MenuItem,
29};
30use crate::models::post::Post;
31use crate::pages::not_found::NotFound;
32use crate::routes::Route;
33use crate::utils::error::failure_message;
34use editor::use_write_editor;
35use metadata::MetadataPanel;
36use state::{PostDraft, SaveIndicator, SaveState};
37
38/// 起步模板:(名称,说明,正文)。「空白」没有正文,只收起入口。
39const STARTERS: [(&str, &str, &str); 3] = [
40    (
41        "技术长文",
42        "带目录与可运行代码",
43        "## 背景\n\n要解决什么问题,为什么值得写。\n\n## 设计\n\n关键取舍与整体结构。\n\n## 实现\n\n```python runnable\nprint(\"hello\")\n```\n\n## 小结\n\n结论与后续。\n",
44    ),
45    (
46        "读书笔记",
47        "引用块与脚注",
48        "> 摘录的原文。\n\n## 读后\n\n写下想法[^1]。\n\n[^1]: 出处:书名,页码。\n",
49    ),
50    ("空白", "从零开始", ""),
51];
52
53/// 新建文章(`/admin/write`)。
54pub struct WriteRoute;
55
56#[lazy_route]
57impl LazyRoute for WriteRoute {
58    fn data() -> Self {
59        Self
60    }
61
62    fn view(_this: Self) -> AnyView {
63        view! { <WriteEditor post_id=None initial=PostDraft::default() published_at=None/> }
64            .into_any()
65    }
66}
67
68/// 编辑文章(`/admin/write/:id`)。
69pub struct WriteEditRoute;
70
71#[lazy_route]
72impl LazyRoute for WriteEditRoute {
73    fn data() -> Self {
74        Self
75    }
76
77    fn view(_this: Self) -> AnyView {
78        view! { <WriteEdit/> }.into_any()
79    }
80}
81
82/// 编辑文章:先取文章,再以服务端内容挂载编辑器。
83///
84/// 私人草稿只在浏览器鉴权后读取,不进入 SSR;取数用 Effect + 镜像信号,
85/// 世代号保证换文章或重试时只认最新一次结果。
86#[component]
87fn WriteEdit() -> impl IntoView {
88    let params = use_params_map();
89    let id = Memo::new(move |_| {
90        params
91            .read()
92            .get("id")
93            .and_then(|id| id.parse::<i32>().ok())
94    });
95    let reload = RwSignal::new(0_u32);
96    // 带上文章 id:换文章时旧结果不能顶替新参数,直到新请求落定。
97    let loaded = RwSignal::new(None::<(i32, Result<Option<Post>, ()>)>);
98    let generation = StoredValue::new(0_u64);
99    Effect::new(move |_| {
100        reload.track();
101        let Some(id) = id.get() else {
102            return;
103        };
104        let Some(current) = generation.try_update_value(|g| {
105            *g += 1;
106            *g
107        }) else {
108            return;
109        };
110        loaded.set(None);
111        leptos::task::spawn_local(async move {
112            let result = get_post_by_id(id).await.map(|res| res.post).map_err(|_| ());
113            if generation.try_get_value() == Some(current) {
114                let _ = loaded.try_set(Some((id, result)));
115            }
116        });
117    });
118
119    move || {
120        let Some(id) = id.get() else {
121            return view! { <NotFound/> }.into_any();
122        };
123        let result = loaded
124            .get()
125            .and_then(|(loaded_id, result)| (loaded_id == id).then_some(result));
126        match result {
127            Some(Ok(Some(post))) => view! {
128                <WriteEditor
129                    post_id=Some(id)
130                    initial=PostDraft::from(post.clone())
131                    published_at=post.published_at
132                />
133            }
134            .into_any(),
135            Some(Ok(None)) => view! {
136                <div class="write-missing">
137                    <EmptyState
138                        icon=IconName::FileX
139                        title="找不到这篇文章"
140                        description="它可能已被移到回收站或彻底删除。"
141                        action=EmptyStateAction::new(
142                            "返回文章",
143                            move |_| crate::navigation::push(&Route::Posts.to_string()),
144                        )
145                    />
146                </div>
147            }
148            .into_any(),
149            Some(Err(())) => view! {
150                <div class="write-missing">
151                    <LoadError
152                        title="文章加载失败"
153                        on_retry=move |_| reload.update(|n| *n += 1)
154                    />
155                </div>
156            }
157            .into_any(),
158            None => view! { <WriteSkeleton/> }.into_any(),
159        }
160    }
161}
162
163/// 编辑器页面本体,新建与编辑共用。
164#[component]
165fn WriteEditor(
166    post_id: Option<i32>,
167    initial: PostDraft,
168    published_at: Option<DateTime<Utc>>,
169) -> impl IntoView {
170    let state = use_write_editor(post_id, initial, published_at);
171    let toast = use_toast();
172    let form = state.form;
173    let slot = post_id.map_or_else(|| "post-new".to_string(), |id| format!("post-{id}"));
174    let (recovery, cache_failed) =
175        use_draft_protection(slot, form, move || state.dirty() || state.busy());
176    let drawer_open = RwSignal::new(true);
177    // 只有点「属性」打开时才播滑入动画;首屏、换页与编辑器挂载时直接出现。
178    let drawer_animate = RwSignal::new(false);
179    // 首屏与 SSR 一致,水合后窄屏默认收起属性抽屉。
180    Effect::new(move |_| {
181        #[cfg(target_arch = "wasm32")]
182        {
183            let wide = web_sys::window()
184                .and_then(|w| w.inner_width().ok())
185                .and_then(|w| w.as_f64())
186                .is_some_and(|w| w >= 1024.0);
187            drawer_open.set(wide);
188        }
189    });
190    let starter_dismissed = RwSignal::new(false);
191
192    let ready = move || state.phase.with(|phase| phase.is_ready());
193    let saving = move || state.save.with(SaveState::is_saving);
194    let recovering = move || recovery.with(Option::is_some);
195    let published = Memo::new(move |_| state.saved.with(PostDraft::is_published));
196    let indicator = Memo::new(move |_| {
197        state.save.with(|save| {
198            SaveIndicator::of(
199                save,
200                state.dirty(),
201                post_id.is_none(),
202                state.saved_at.get(),
203                recovering(),
204            )
205        })
206    });
207    let complete = Memo::new(move |_| {
208        form.with(|d| !d.title.trim().is_empty() && !d.content.trim().is_empty())
209    });
210    let can_save = move || ready() && !recovering() && !saving() && complete.get();
211    let save_hint = move || (!complete.get()).then_some("填写标题和正文后才能保存");
212    let show_starters = move || {
213        post_id.is_none()
214            && ready()
215            && !recovering()
216            && !starter_dismissed.get()
217            && form.with(|d| d.content.trim().is_empty())
218    };
219    let crumb = move || {
220        form.with(|d| {
221            if d.title.trim().is_empty() {
222                if post_id.is_some() {
223                    "未命名"
224                } else {
225                    "新建"
226                }
227                .to_string()
228            } else {
229                d.title.clone()
230            }
231        })
232    };
233    let tag_count = move || form.with(|d| d.parsed_tags().len());
234    let published_on = move || {
235        state
236            .published_at
237            .get()
238            .filter(|_| published.get())
239            .map(|at| crate::utils::time::ymd(&crate::utils::time::to_local(at), '.'))
240    };
241    let saved_slug = move || state.saved.with(|s| s.slug.clone());
242
243    let submit = move |status: &'static str| save::submit(state, toast, status);
244    let retry = move |_| {
245        let current = state.save.get_untracked();
246        if let SaveState::Failed { status, .. } = current {
247            save::submit(state, toast, status);
248        }
249    };
250    let move_to_trash = move |_| {
251        let Some(id) = post_id else {
252            return;
253        };
254        leptos::task::spawn_local(async move {
255            match crate::api::posts::delete_post(id).await {
256                Ok(res) if res.success => {
257                    toast.success("已移到回收站");
258                    crate::navigation::push(&Route::Posts.to_string());
259                }
260                Ok(res) => toast.error(res.message),
261                Err(e) => toast.error(failure_message("移到回收站失败", &e)),
262            }
263        });
264    };
265
266    let insert_picked_images = move |picks: Vec<AssetSelection>| {
267        #[cfg(target_arch = "wasm32")]
268        state.editor.with_value(|handle| {
269            if let Some(handle) = handle {
270                if let Ok(json) = serde_json::to_string(&picks) {
271                    handle.instance().insert_images_from_library(&json);
272                }
273            }
274        });
275        #[cfg(not(target_arch = "wasm32"))]
276        let _ = picks;
277    };
278
279    view! {
280        <div
281            class="write-page"
282            on:keydown=move |e: leptos::ev::KeyboardEvent| {
283                if (e.meta_key() || e.ctrl_key()) && e.key() == "s" {
284                    e.prevent_default();
285                    if can_save() {
286                        submit(if published.get_untracked() { "published" } else { "draft" });
287                    }
288                }
289            }
290        >
291            <header class="write-head">
292                <div class="write-head-left">
293                    <a
294                        class=format!("{} btn-icon", button_class(ButtonVariant::Ghost, ButtonSize::Md))
295                        href=Route::Posts.to_string()
296                        aria-label="返回文章列表"
297                    >
298                        <Icon name=IconName::ArrowLeft size=16/>
299                    </a>
300                    <nav class="write-crumbs" aria-label="面包屑">
301                        <a href=Route::Posts.to_string()>"文章"</a>
302                        <span aria-hidden="true">"/"</span>
303                        <span aria-current="page">{crumb}</span>
304                    </nav>
305                </div>
306                <div class="write-head-actions">
307                    {move || {
308                        let indicator = indicator.get();
309                        if indicator == SaveIndicator::Failed {
310                            view! {
311                                <button
312                                    class="write-status"
313                                    type="button"
314                                    data-tone=indicator.tone()
315                                    title=move || {
316                                        state
317                                            .save
318                                            .with(|save| match save {
319                                                SaveState::Failed { message, .. } => Some(message.clone()),
320                                                _ => None,
321                                            })
322                                    }
323                                    on:click=retry
324                                >
325                                    <span class="write-status-dot" aria-hidden="true"></span>
326                                    <span role="status">{indicator.label()}</span>
327                                </button>
328                            }
329                                .into_any()
330                        } else {
331                            view! {
332                                <span
333                                    class="write-status"
334                                    data-tone=indicator.tone()
335                                    data-pulse=(indicator == SaveIndicator::Saving).then_some("true")
336                                >
337                                    <span class="write-status-dot" aria-hidden="true"></span>
338                                    <span role="status" aria-live="polite">
339                                        {indicator.label()}
340                                    </span>
341                                </span>
342                            }
343                                .into_any()
344                        }
345                    }}
346                    {post_id
347                        .is_some()
348                        .then(|| {
349                            view! {
350                                <a
351                                    class=format!(
352                                        "{} write-hide-narrow",
353                                        button_class(ButtonVariant::Ghost, ButtonSize::Md),
354                                    )
355                                    href=move || Route::PostPreview { slug: saved_slug() }.to_string()
356                                >
357                                    "预览"
358                                </a>
359                            }
360                        })}
361                    <button
362                        class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
363                        type="button"
364                        aria-pressed=move || drawer_open.get().to_string()
365                        aria-controls="write-drawer"
366                        // 窄屏只剩图标,名称由 aria-label 补出。
367                        aria-label="属性"
368                        on:click=move |_| {
369                            drawer_animate.set(true);
370                            drawer_open.update(|open| *open = !*open);
371                        }
372                    >
373                        <Icon name=IconName::PanelRight size=15/>
374                        <span class="write-hide-narrow">"属性"</span>
375                    </button>
376                    {move || {
377                        if published.get() {
378                            view! {
379                                <button
380                                    class=button_class(ButtonVariant::Primary, ButtonSize::Md)
381                                    type="button"
382                                    disabled=move || !can_save()
383                                    title=save_hint
384                                    on:click=move |_| submit("published")
385                                >
386                                    "更新"
387                                </button>
388                                <Menu
389                                    label="更多操作"
390                                    items=vec![MenuItem::new("unpublish", "转为草稿").icon(IconName::EyeOff)]
391                                    on_select=move |_| {
392                                        if can_save() {
393                                            submit("draft");
394                                        }
395                                    }
396                                />
397                            }
398                                .into_any()
399                        } else {
400                            view! {
401                                <button
402                                    class=format!(
403                                        "{} write-hide-narrow",
404                                        button_class(ButtonVariant::Ghost, ButtonSize::Md),
405                                    )
406                                    type="button"
407                                    disabled=move || !can_save()
408                                    title=save_hint
409                                    on:click=move |_| submit("draft")
410                                >
411                                    "存草稿"
412                                </button>
413                                <button
414                                    class=button_class(ButtonVariant::Primary, ButtonSize::Md)
415                                    type="button"
416                                    disabled=move || !can_save()
417                                    title=save_hint
418                                    on:click=move |_| submit("published")
419                                >
420                                    "发布"
421                                </button>
422                            }
423                                .into_any()
424                        }
425                    }}
426                </div>
427            </header>
428
429            {move || {
430                recovery
431                    .get()
432                    .map(|local| {
433                        let restore = local.clone();
434                        view! {
435                            <div class="write-banner" role="alert">
436                                <Icon name=IconName::RotateCcwClock size=18/>
437                                <div>
438                                    <p class="write-banner-title">"这个标签页留有未保存的内容"</p>
439                                    <p>
440                                        "本地副本与服务端版本不同。恢复会替换编辑器里的内容,保存后才生效;选择之前编辑器暂停输入。"
441                                    </p>
442                                    <div class="write-banner-actions">
443                                        <button
444                                            class=button_class(ButtonVariant::Primary, ButtonSize::Sm)
445                                            type="button"
446                                            disabled=move || !ready()
447                                            on:click=move |_| {
448                                                form.set(restore.clone());
449                                                state.set_content(&restore.content);
450                                                recovery.set(None);
451                                            }
452                                        >
453                                            "恢复本地副本"
454                                        </button>
455                                        <button
456                                            class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
457                                            type="button"
458                                            on:click=move |_| recovery.set(None)
459                                        >
460                                            "使用服务端版本"
461                                        </button>
462                                    </div>
463                                </div>
464                            </div>
465                        }
466                    })
467            }}
468
469            <div class="write-body">
470                <main class="write-main" data-vt-scroll="admin-write-content">
471                    <div class="write-doc">
472                        <LibraryLoadError library=state.library/>
473                        {move || {
474                            state
475                                .phase
476                                .with(|phase| phase.error().map(str::to_owned))
477                                .map(|message| {
478                                    view! {
479                                        <p class="write-notice" role="alert">
480                                            <span>{message}</span>
481                                            <button
482                                                class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
483                                                type="button"
484                                                on:click=move |_| state.retry()
485                                            >
486                                                "重试"
487                                            </button>
488                                        </p>
489                                    }
490                                })
491                        }}
492                        {move || {
493                            cache_failed
494                                .get()
495                                .then(|| {
496                                    view! {
497                                        <p class="write-notice" role="alert">
498                                            "浏览器无法保存本地恢复副本,请先保存再离开。"
499                                        </p>
500                                    }
501                                })
502                        }}
503                        // 同 id 的错误消息会原地更新,key 带上消息让它随之重绘。
504                        <For
505                            each=move || state.upload_errors.get()
506                            key=|err| (err.id.clone(), err.message.clone())
507                            let:err
508                        >
509                            <p class="write-notice" role="alert">
510                                <span>
511                                    {format!("图片上传失败:{} — {}", err.file_name, err.message)}
512                                </span>
513                                <IconButton
514                                    icon=IconName::X
515                                    label="关闭提示"
516                                    size=ButtonSize::Sm
517                                    onclick={
518                                        let id = err.id.clone();
519                                        move |_| {
520                                            #[cfg(target_arch = "wasm32")]
521                                            state.editor.with_value(|handle| {
522                                                if let Some(handle) = handle {
523                                                    handle.instance().remove_upload_by_upload_id(&id);
524                                                }
525                                            });
526                                            state.upload_errors.update(|errors| errors.retain(|e| e.id != id));
527                                        }
528                                    }
529                                />
530                            </p>
531                        </For>
532
533                        <label class="sr-only" for="write-title">
534                            "标题"
535                        </label>
536                        // 单段文本框随内容换行;回车不换行,直接进入正文。
537                        <textarea
538                            id="write-title"
539                            class="write-title"
540                            rows="1"
541                            placeholder="文章标题"
542                            readonly=recovering
543                            prop:value=move || form.with(|d| d.title.clone())
544                            on:input=move |e| {
545                                let title = event_target_value(&e).replace(['\n', '\r'], "");
546                                form.update(|d| d.title = title);
547                            }
548                            on:keydown=move |e: leptos::ev::KeyboardEvent| {
549                                if e.key() == "Enter" && !e.is_composing() {
550                                    e.prevent_default();
551                                    focus_editor();
552                                }
553                            }
554                        >
555                            {form.with_untracked(|d| d.title.clone())}
556                        </textarea>
557                        <p class="write-meta">
558                            {move || {
559                                let slug = saved_slug();
560                                if slug.is_empty() {
561                                    view! { <span>"保存后生成链接"</span> }.into_any()
562                                } else {
563                                    view! { <span class="write-meta-slug">{format!("/post/{slug}")}</span> }
564                                        .into_any()
565                                }
566                            }}
567                            {move || {
568                                let count = tag_count();
569                                (count > 0).then(|| view! { <span>{format!("{count} 个标签")}</span> })
570                            }}
571                            {move || match published_on() {
572                                Some(date) => view! { <span>{format!("已发布于 {date}")}</span> }.into_any(),
573                                None => view! { <span>"草稿 · 未发布"</span> }.into_any(),
574                            }}
575                        </p>
576                        <div class="write-rule" aria-hidden="true"></div>
577
578                        // inert 只在等待选择恢复时出现:属性存在即生效,不能写成 false。
579                        <div
580                            class="write-editor-wrap"
581                            inert=move || recovering().then_some("true")
582                            data-empty=move || show_starters().then_some("true")
583                        >
584                            <div id="tiptap-editor" class="write-editor" node_ref=state.container></div>
585                        </div>
586
587                        {move || {
588                            show_starters()
589                                .then(|| {
590                                    view! {
591                                        <div class="write-starters" role="group" aria-label="起步模板">
592                                            {STARTERS
593                                                .iter()
594                                                .map(|&(name, hint, body)| {
595                                                    view! {
596                                                        <button
597                                                            class="write-starter"
598                                                            type="button"
599                                                            on:click=move |_| {
600                                                                starter_dismissed.set(true);
601                                                                if !body.is_empty() {
602                                                                    state.set_content(body);
603                                                                }
604                                                                focus_editor();
605                                                            }
606                                                        >
607                                                            <span class="write-starter-name">{name}</span>
608                                                            <span class="write-starter-hint">{hint}</span>
609                                                        </button>
610                                                    }
611                                                })
612                                                .collect_view()}
613                                        </div>
614                                        <p class="write-starter-note">
615                                            "标题和正文都填写后才能保存;摘要、标签和封面可以稍后在「属性」里补。"
616                                        </p>
617                                    }
618                                })
619                        }}
620                    </div>
621                </main>
622                {move || {
623                    drawer_open
624                        .get()
625                        .then(|| {
626                            view! {
627                                <div
628                                    id="write-drawer"
629                                    class="write-drawer-slot"
630                                    data-enter=move || drawer_animate.get().then_some("true")
631                                >
632                                    <MetadataPanel state open=drawer_open on_trash=move_to_trash/>
633                                </div>
634                            }
635                        })
636                }}
637            </div>
638
639            {move || {
640                (state.phase.with(|phase| phase.is_loading())
641                    && state.library.read_state().is_none_or(|r| r.is_ok()))
642                    .then(|| view! { <div class="write-loading"><WriteSkeleton/></div> })
643            }}
644
645            // 正文插图:素材库多选。
646            <AssetPickerModal
647                visible=state.body_picker_visible
648                cover_uploading=state.body_picker_uploading
649                title="选择图片"
650                multi=true
651                on_select=insert_picked_images
652            />
653        </div>
654    }
655}
656
657/// 把焦点放进正文编辑区。
658fn focus_editor() {
659    #[cfg(target_arch = "wasm32")]
660    {
661        use wasm_bindgen::JsCast;
662        if let Some(el) = web_sys::window()
663            .and_then(|w| w.document())
664            .and_then(|d| {
665                d.query_selector("#tiptap-editor [contenteditable]")
666                    .ok()
667                    .flatten()
668            })
669            .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
670        {
671            let _ = el.focus();
672        }
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::STARTERS;
679
680    #[test]
681    fn starters_offer_long_form_reading_notes_and_blank() {
682        let names: Vec<_> = STARTERS.iter().map(|s| s.0).collect();
683        assert_eq!(names, ["技术长文", "读书笔记", "空白"]);
684        assert!(STARTERS[0].2.contains("runnable"));
685        assert!(STARTERS[1].2.contains("[^1]:"));
686        assert!(STARTERS[2].2.is_empty());
687    }
688}