yggdrasil/pages/admin/
preview.rs1use crate::api::posts::{get_post_preview, SinglePostResponse};
26use crate::components::empty_state::{EmptyState, EmptyStateAction};
27use crate::components::post::post_content::PostContent;
28use crate::components::post::post_cover::PostCover;
29use crate::components::post::post_footer::PostFooter;
30use crate::components::post::post_header::PostHeader;
31use crate::components::post::post_toc::PostToc;
32use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
33use crate::components::skeletons::post_preview_skeleton::PostPreviewSkeleton;
34use crate::components::ui::{
35 button_class, use_toast, ButtonSize, ButtonVariant, Icon, IconName, SegmentedControl,
36 SegmentedItem, SegmentedKind, SegmentedSize,
37};
38use crate::models::post::{Post, PostFooterData, PostHeaderData, PostStatus};
39use crate::routes::Route;
40use crate::utils::error::failure_message;
41use leptos::prelude::*;
42use leptos_router::hooks::use_params_map;
43use leptos_router::{lazy_route, LazyRoute};
44
45pub struct PreviewRoute;
47
48#[lazy_route]
49impl LazyRoute for PreviewRoute {
50 fn data() -> Self {
51 Self
52 }
53
54 fn view(_this: Self) -> AnyView {
55 view! { <PostPreview/> }.into_any()
56 }
57}
58
59#[derive(Clone, PartialEq)]
61enum Branch {
62 Loading,
64 Missing,
66 Ready,
68}
69
70#[component]
75pub fn PostPreview() -> impl IntoView {
76 let params = use_params_map();
77 let slug = Memo::new(move |_| params.read().get("slug").unwrap_or_default());
78
79 let toast = use_toast();
80 let mobile = RwSignal::new(false);
81 let publishing = RwSignal::new(false);
82 let reload = RwSignal::new(0_u32);
84 let post = RwSignal::new(None::<Option<Post>>);
86 let loaded_slug = RwSignal::new(String::new());
88
89 let generation = StoredValue::new(0_u64);
91 let bump_generation = move || {
92 generation
93 .try_update_value(|g| {
94 *g = g.wrapping_add(1);
95 *g
96 })
97 .unwrap_or(0)
98 };
99 on_cleanup(move || {
100 bump_generation();
101 });
102
103 Effect::new(move |_| {
104 let current_slug = slug.get();
105 let _ = reload.get();
106 if loaded_slug.with_untracked(|s| s != ¤t_slug) {
107 post.set(None);
108 }
109 let request_id = bump_generation();
110 leptos::task::spawn_local(async move {
111 let resp = get_post_preview(current_slug.clone()).await;
112 if generation.try_get_value() != Some(request_id) {
113 return;
114 }
115 post.try_set(Some(resp.ok().and_then(|SinglePostResponse { post }| post)));
116 loaded_slug.try_set(current_slug);
117 });
118 });
119
120 let branch = Memo::new(move |_| {
121 if loaded_slug.with(|l| l != &slug.get()) {
122 return Branch::Loading;
123 }
124 match post.with(|p| p.as_ref().map(Option::is_some)) {
125 None => Branch::Loading,
126 Some(false) => Branch::Missing,
127 Some(true) => Branch::Ready,
128 }
129 });
130
131 move || match branch.get() {
132 Branch::Loading => view! {
133 <DelayedSkeleton>
134 <PostPreviewSkeleton/>
135 </DelayedSkeleton>
136 }
137 .into_any(),
138 Branch::Missing => view! {
139 <div class="admin-panel">
140 <EmptyState
141 icon=IconName::FileX
142 title="找不到这篇文章"
143 description="它可能已被移到回收站或彻底删除。"
144 action=EmptyStateAction::new(
145 "返回文章",
146 move |_| crate::navigation::push(&Route::Posts.to_string()),
147 )
148 />
149 </div>
150 }
151 .into_any(),
152 Branch::Ready => {
153 let Some((post_id, post_slug)) = post.with_untracked(|p| {
154 p.as_ref()
155 .and_then(Option::as_ref)
156 .map(|p| (p.id, p.slug.clone()))
157 }) else {
158 return ().into_any();
159 };
160 let draft = Memo::new(move |_| {
161 post.with(|p| {
162 p.as_ref()
163 .and_then(Option::as_ref)
164 .is_none_or(|p| p.status != PostStatus::Published)
165 })
166 });
167 let publish = move |_| {
168 if publishing.get_untracked() {
169 return;
170 }
171 publishing.set(true);
172 leptos::task::spawn_local(async move {
173 match crate::api::posts::batch_posts(
174 vec![post_id],
175 crate::api::posts::BatchPostAction::Publish,
176 )
177 .await
178 {
179 Ok(res) if res.done == 1 => {
180 toast.success("已发布");
181 reload.try_update(|n| *n += 1);
182 }
183 Ok(_) => toast.error("发布失败,请回到编辑页检查后重试"),
184 Err(e) => toast.error(failure_message("发布失败", &e)),
185 }
186 publishing.try_set(false);
187 });
188 };
189
190 view! {
191 <div
192 class="post-preview"
193 data-width=move || if mobile.get() { "mobile" } else { "desktop" }
194 >
195 <div class="preview-bar-slot">
196 <div class="preview-bar" role="region" aria-label="预览状态">
197 <span class="preview-bar-state">
198 <span
199 class="preview-bar-dot"
200 data-draft=move || draft.get().then_some("true")
201 aria-hidden="true"
202 ></span>
203 <strong>{move || if draft.get() { "草稿预览" } else { "文章预览" }}</strong>
204 <span class="preview-bar-hint">
205 {move || {
206 if draft.get() {
207 "仅管理员可见 · 显示最近一次保存的内容"
208 } else {
209 "已发布 · 显示最近一次保存的内容"
210 }
211 }}
212 </span>
213 </span>
214 <span class="preview-bar-sep" aria-hidden="true"></span>
215 <div class="preview-bar-actions">
216 <SegmentedControl
217 items=vec![
218 SegmentedItem::new(false, "桌面"),
219 SegmentedItem::new(true, "手机"),
220 ]
221 value=mobile
222 on_change=move |v| mobile.set(v)
223 aria_label="预览宽度"
224 kind=SegmentedKind::Radio
225 size=SegmentedSize::Sm
226 class="preview-viewport-switch"
227 />
228 <a
229 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
230 href=Route::WriteEdit { id: post_id }.to_string()
231 >
232 "返回编辑"
233 </a>
234 {move || {
235 if draft.get() {
236 view! {
237 <button
238 class=button_class(ButtonVariant::Primary, ButtonSize::Sm)
239 type="button"
240 disabled=move || publishing.get()
241 on:click=publish
242 >
243 {move || if publishing.get() { "发布中…" } else { "发布" }}
244 </button>
245 }
246 .into_any()
247 } else {
248 view! {
249 <a
250 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
251 href=format!("/post/{post_slug}")
252 target="_blank"
253 rel="noopener"
254 >
255 "查看文章"
256 <Icon name=IconName::ArrowUpRight size=13/>
257 </a>
258 }
259 .into_any()
260 }
261 }}
262 </div>
263 </div>
264 </div>
265
266 <div class="preview-frame">
267 <article
268 class="post-single animate-page-enter"
269 data-vt-detail=post_id.to_string()
270 >
271 {move || post.with(|p| p.as_ref().and_then(Option::as_ref).map(post_body))}
272 </article>
273 </div>
274 </div>
275 }
276 .into_any()
277 }
278 }
279}
280
281fn post_body(post: &Post) -> impl IntoView {
283 view! {
284 <PostHeader post=PostHeaderData::from(post)/>
285
286 {post
288 .cover_image
289 .clone()
290 .map(|cover| view! { <PostCover src=cover post_id=post.id/> })}
291
292 {post.toc_html.clone().map(|toc| view! { <PostToc toc_html=toc/> })}
293
294 <PostContent content_html=post.content_html.clone().unwrap_or_default()/>
295 <PostFooter post=PostFooterData::from(post)/>
296 <p class="preview-note">"评论区在预览中隐藏。发布后读者看到的版式与这里一致。"</p>
297 }
298}