Skip to main content

yggdrasil/pages/admin/write/
state.rs

1//! Form data, the saved baseline and the editor lifecycle. Readiness has one state, so
2//! a failed initialization cannot leave the page trapped under the loading skeleton.
3use chrono::{DateTime, Utc};
4use leptos::prelude::*;
5
6use crate::bridges::library::LibraryResource;
7use crate::bridges::tiptap::{UploadErrorEntry, UploadsInFlight};
8use crate::models::post::Post;
9
10#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
11pub(super) struct PostDraft {
12    pub title: String,
13    pub summary: String,
14    pub slug: String,
15    pub tags: String,
16    pub cover_image: String,
17    pub status: String,
18    pub content: String,
19}
20
21impl Default for PostDraft {
22    fn default() -> Self {
23        Self {
24            title: String::new(),
25            summary: String::new(),
26            slug: String::new(),
27            tags: String::new(),
28            cover_image: String::new(),
29            status: "draft".into(),
30            content: String::new(),
31        }
32    }
33}
34
35impl PostDraft {
36    pub fn parsed_tags(&self) -> Vec<String> {
37        self.tags
38            .split([',', ',', ';', ';'])
39            .map(str::trim)
40            .filter(|tag| !tag.is_empty())
41            .map(str::to_owned)
42            .collect()
43    }
44
45    /// 追加标签(输入里的分隔符都算),已有的不重复。
46    pub fn add_tags(&mut self, input: &str) {
47        let mut tags = self.parsed_tags();
48        let probe = PostDraft {
49            tags: input.to_owned(),
50            ..PostDraft::default()
51        };
52        for tag in probe.parsed_tags() {
53            if !tags.contains(&tag) {
54                tags.push(tag);
55            }
56        }
57        self.tags = tags.join(", ");
58    }
59
60    pub fn remove_tag(&mut self, tag: &str) {
61        let tags: Vec<String> = self
62            .parsed_tags()
63            .into_iter()
64            .filter(|t| t != tag)
65            .collect();
66        self.tags = tags.join(", ");
67    }
68
69    pub fn is_published(&self) -> bool {
70        self.status == "published"
71    }
72}
73
74impl From<Post> for PostDraft {
75    fn from(post: Post) -> Self {
76        Self {
77            title: post.title,
78            summary: post.summary.unwrap_or_default(),
79            slug: post.slug,
80            tags: post.tags.join(", "),
81            cover_image: post.cover_image.unwrap_or_default(),
82            status: post.status.as_str().into(),
83            content: post.content_md,
84        }
85    }
86}
87
88#[derive(Clone, Debug, PartialEq)]
89#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
90pub(super) enum EditorPhase {
91    Initializing,
92    Ready,
93    Failed(String),
94}
95
96impl EditorPhase {
97    pub fn is_loading(&self) -> bool {
98        matches!(self, Self::Initializing)
99    }
100
101    pub fn is_ready(&self) -> bool {
102        matches!(self, Self::Ready)
103    }
104
105    pub fn error(&self) -> Option<&str> {
106        match self {
107            Self::Failed(message) => Some(message),
108            _ => None,
109        }
110    }
111}
112
113#[derive(Clone, Debug, Default, PartialEq)]
114#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
115pub(super) enum SaveState {
116    #[default]
117    Idle,
118    Saving,
119    /// 失败信息与那次要保存成的状态,点状态指示可按原状态重试。
120    Failed {
121        message: String,
122        status: &'static str,
123    },
124}
125
126impl SaveState {
127    pub fn is_saving(&self) -> bool {
128        matches!(self, Self::Saving)
129    }
130}
131
132/// 页头的保存状态指示。
133#[derive(Clone, Debug, PartialEq)]
134pub(super) enum SaveIndicator {
135    /// 新文章还没保存过。
136    NotSaved,
137    /// 与服务端一致;本页保存过时带时间。
138    Saved(Option<String>),
139    Dirty,
140    Saving,
141    Failed,
142    /// 本地副本等待选择恢复或放弃。
143    Waiting,
144}
145
146impl SaveIndicator {
147    pub fn of(
148        save: &SaveState,
149        dirty: bool,
150        never_saved: bool,
151        saved_at: Option<String>,
152        recovering: bool,
153    ) -> Self {
154        match save {
155            _ if recovering => Self::Waiting,
156            SaveState::Saving => Self::Saving,
157            SaveState::Failed { .. } => Self::Failed,
158            SaveState::Idle if dirty => Self::Dirty,
159            SaveState::Idle if never_saved => Self::NotSaved,
160            SaveState::Idle => Self::Saved(saved_at),
161        }
162    }
163
164    pub fn label(&self) -> String {
165        match self {
166            Self::NotSaved => "尚未保存".into(),
167            Self::Saved(Some(at)) => format!("已保存 · {at}"),
168            Self::Saved(None) => "已保存".into(),
169            Self::Dirty => "有未保存的修改".into(),
170            Self::Saving => "正在保存…".into(),
171            Self::Failed => "保存失败 · 点击重试".into(),
172            Self::Waiting => "等待选择".into(),
173        }
174    }
175
176    /// 圆点色调,对应 CSS 的 data-tone。
177    pub fn tone(&self) -> &'static str {
178        match self {
179            Self::NotSaved | Self::Saved(_) => "muted",
180            Self::Dirty | Self::Saving | Self::Waiting => "clay",
181            Self::Failed => "danger",
182        }
183    }
184}
185
186#[derive(Clone, Copy)]
187pub(super) struct WriteEditorState {
188    /// 编辑中的文章;新文章为 None,首次保存后换到编辑路由。
189    pub post_id: Option<i32>,
190    pub form: RwSignal<PostDraft>,
191    /// 最近一次与服务端一致的内容,form 与它不同即有未保存修改。
192    pub saved: RwSignal<PostDraft>,
193    pub saved_at: RwSignal<Option<String>>,
194    pub published_at: RwSignal<Option<DateTime<Utc>>>,
195    pub phase: RwSignal<EditorPhase>,
196    pub save: RwSignal<SaveState>,
197    pub library: LibraryResource,
198    pub uploads_in_flight: RwSignal<UploadsInFlight>,
199    pub upload_errors: RwSignal<Vec<UploadErrorEntry>>,
200    pub cover_uploading: RwSignal<bool>,
201    pub body_picker_visible: RwSignal<bool>,
202    pub body_picker_uploading: RwSignal<bool>,
203    pub(super) reload: RwSignal<u32>,
204    /// 编辑器挂载容器;`create` 在此元素上创建实例。
205    pub container: NodeRef<leptos::html::Div>,
206    /// 编辑器句柄持有 JS 实例与回调,不是 `Send + Sync`,只能放本地存储;
207    /// 随页面所有者销毁时 drop,触发 JS 端 destroy。
208    #[cfg(target_arch = "wasm32")]
209    pub editor: StoredValue<Option<crate::bridges::tiptap::EditorHandle>, LocalStorage>,
210}
211
212impl WriteEditorState {
213    pub fn retry(self) {
214        self.reload.update(|n| *n += 1);
215    }
216
217    /// 非响应式读取与否取决于调用处:在 Effect/视图闭包里调用即订阅。
218    pub fn dirty(self) -> bool {
219        self.form
220            .with(|form| self.saved.with(|saved| form != saved))
221    }
222
223    /// 保存或上传尚未完成,此时离开会丢内容。
224    pub fn busy(self) -> bool {
225        self.save.with(SaveState::is_saving)
226            || self.uploads_in_flight.with(|u| u.uploading > 0)
227            || self.cover_uploading.get()
228            || self.body_picker_uploading.get()
229    }
230
231    /// 把正文换成给定 Markdown(起步模板、恢复本地副本)。
232    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
233    pub fn set_content(self, markdown: &str) {
234        #[cfg(target_arch = "wasm32")]
235        self.editor.with_value(|handle| {
236            if let Some(handle) = handle {
237                let _ = handle.instance().set_markdown(markdown);
238            }
239        });
240        self.form.update(|form| form.content = markdown.to_owned());
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn tags_add_without_duplicates_and_remove_by_name() {
250        let mut draft = PostDraft {
251            tags: "Rust, 架构".into(),
252            ..PostDraft::default()
253        };
254        draft.add_tags("架构,Leptos;");
255        assert_eq!(draft.tags, "Rust, 架构, Leptos");
256        draft.remove_tag("架构");
257        assert_eq!(draft.parsed_tags(), ["Rust", "Leptos"]);
258    }
259
260    #[test]
261    fn new_posts_start_as_drafts() {
262        assert!(!PostDraft::default().is_published());
263    }
264
265    #[test]
266    fn indicator_prefers_saving_and_failure_over_dirty() {
267        let failed = SaveState::Failed {
268            message: "x".into(),
269            status: "draft",
270        };
271        assert_eq!(
272            SaveIndicator::of(&SaveState::Saving, true, false, None, false),
273            SaveIndicator::Saving
274        );
275        assert_eq!(
276            SaveIndicator::of(&failed, true, false, None, false),
277            SaveIndicator::Failed
278        );
279        assert_eq!(
280            SaveIndicator::of(&SaveState::Idle, true, true, None, false),
281            SaveIndicator::Dirty
282        );
283        assert_eq!(
284            SaveIndicator::of(&SaveState::Idle, false, true, None, false),
285            SaveIndicator::NotSaved
286        );
287        assert_eq!(
288            SaveIndicator::of(&SaveState::Idle, false, false, Some("15:42".into()), false).label(),
289            "已保存 · 15:42"
290        );
291        assert_eq!(
292            SaveIndicator::of(&SaveState::Saving, true, false, None, true),
293            SaveIndicator::Waiting
294        );
295    }
296}