yggdrasil/pages/admin/write/
state.rs1use 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 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 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#[derive(Clone, Debug, PartialEq)]
134pub(super) enum SaveIndicator {
135 NotSaved,
137 Saved(Option<String>),
139 Dirty,
140 Saving,
141 Failed,
142 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 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 pub post_id: Option<i32>,
190 pub form: RwSignal<PostDraft>,
191 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 pub container: NodeRef<leptos::html::Div>,
206 #[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 pub fn dirty(self) -> bool {
219 self.form
220 .with(|form| self.saved.with(|saved| form != saved))
221 }
222
223 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 #[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}