1use 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
38const 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
53pub 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
68pub 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#[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 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#[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 let drawer_animate = RwSignal::new(false);
179 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="属性"
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 <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 <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 <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 <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
657fn 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}