1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub enum PostStatus {
12 Draft,
14 Published,
16}
17
18impl PostStatus {
19 pub fn as_str(&self) -> &'static str {
21 match self {
22 PostStatus::Draft => "draft",
23 PostStatus::Published => "published",
24 }
25 }
26
27 pub fn label(&self) -> &'static str {
29 match self {
30 PostStatus::Draft => "草稿",
31 PostStatus::Published => "已发布",
32 }
33 }
34
35 pub fn badge_class(&self) -> &'static str {
37 match self {
38 PostStatus::Published => {
39 "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
40 }
41 PostStatus::Draft => "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400",
42 }
43 }
44
45 #[cfg(feature = "server")]
47 pub fn from_str(s: &str) -> Option<Self> {
48 match s {
49 "draft" => Some(PostStatus::Draft),
50 "published" => Some(PostStatus::Published),
51 _ => None,
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct Post {
59 pub id: i32,
61 pub author_id: i32,
63 pub title: String,
65 pub slug: String,
67 pub summary: Option<String>,
69 pub content_md: String,
71 pub content_html: Option<String>,
73 pub status: PostStatus,
75 pub published_at: Option<DateTime<Utc>>,
77 pub created_at: DateTime<Utc>,
79 pub updated_at: DateTime<Utc>,
81 pub deleted_at: Option<DateTime<Utc>>,
83 pub tags: Vec<String>,
85 pub cover_image: Option<String>,
87 pub reading_time: u32,
89 pub word_count: u32,
91 pub toc_html: Option<String>,
93 pub prev_post: Option<PostNav>,
95 pub next_post: Option<PostNav>,
97}
98
99fn format_date(dt: DateTime<Utc>) -> String {
103 dt.format("%Y-%m-%d").to_string()
104}
105
106impl Post {
107 pub fn formatted_date(&self) -> String {
109 format_date(self.published_at.unwrap_or(self.created_at))
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct PostListItem {
119 pub id: i32,
121 pub author_id: i32,
123 pub title: String,
125 pub slug: String,
127 pub summary: Option<String>,
129 pub status: PostStatus,
131 pub published_at: Option<DateTime<Utc>>,
133 pub created_at: DateTime<Utc>,
135 pub updated_at: DateTime<Utc>,
137 pub deleted_at: Option<DateTime<Utc>>,
139 pub tags: Vec<String>,
141 pub cover_image: Option<String>,
143 pub reading_time: u32,
145 pub word_count: u32,
147}
148
149impl PostListItem {
150 pub fn formatted_date(&self) -> String {
152 format_date(self.published_at.unwrap_or(self.created_at))
153 }
154
155 pub fn status_label(&self) -> &'static str {
157 self.status.label()
158 }
159
160 pub fn status_class(&self) -> &'static str {
162 match self.status {
163 PostStatus::Published => "text-green-600 dark:text-green-400",
164 PostStatus::Draft => "text-gray-400 dark:text-gray-500",
165 }
166 }
167
168 #[allow(dead_code)]
170 pub fn status_badge_class(&self) -> &'static str {
171 self.status.badge_class()
172 }
173}
174
175#[cfg(any(feature = "server", test))]
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct FeedItem {
182 pub title: String,
184 pub slug: String,
186 pub summary: Option<String>,
188 pub content_html: Option<String>,
190 pub published_at: DateTime<Utc>,
192 pub updated_at: DateTime<Utc>,
194 pub tags: Vec<String>,
196}
197
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct PostNav {
201 pub title: String,
203 pub slug: String,
205}
206
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct Tag {
210 pub id: i32,
212 pub name: String,
214 pub post_count: i64,
216}
217
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct PostStats {
221 pub total: i64,
223 pub drafts: i64,
225 pub published: i64,
227 pub trash: i64,
229 pub recent_30d: i64,
231 pub activity_30d: Vec<i64>,
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use chrono::{TimeZone, Utc};
240
241 fn sample_post() -> Post {
242 Post {
243 id: 1,
244 author_id: 1,
245 title: "Test".to_string(),
246 slug: "test".to_string(),
247 summary: None,
248 content_md: "content".to_string(),
249 content_html: None,
250 status: PostStatus::Draft,
251 published_at: None,
252 created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
253 updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
254 deleted_at: None,
255 tags: vec![],
256 cover_image: None,
257 reading_time: 1,
258 word_count: 10,
259 toc_html: None,
260 prev_post: None,
261 next_post: None,
262 }
263 }
264
265 fn sample_post_list_item() -> PostListItem {
266 PostListItem {
267 id: 1,
268 author_id: 1,
269 title: "Test".to_string(),
270 slug: "test".to_string(),
271 summary: None,
272 status: PostStatus::Draft,
273 published_at: None,
274 created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
275 updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
276 deleted_at: None,
277 tags: vec![],
278 cover_image: None,
279 reading_time: 1,
280 word_count: 10,
281 }
282 }
283
284 #[test]
285 #[cfg(feature = "server")]
286 fn post_status_from_str() {
287 assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
288 assert_eq!(
289 PostStatus::from_str("published"),
290 Some(PostStatus::Published)
291 );
292 assert_eq!(PostStatus::from_str("unknown"), None);
293 assert_eq!(PostStatus::from_str(""), None);
294 }
295
296 #[test]
297 fn post_status_as_str() {
298 assert_eq!(PostStatus::Draft.as_str(), "draft");
299 assert_eq!(PostStatus::Published.as_str(), "published");
300 }
301
302 #[test]
303 #[cfg(feature = "server")]
304 fn post_status_roundtrip() {
305 for status in [PostStatus::Draft, PostStatus::Published] {
306 assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
307 }
308 }
309
310 #[test]
311 fn formatted_date_uses_published_at_when_available() {
312 let mut post = sample_post();
313 post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
314 assert_eq!(post.formatted_date(), "2024-06-01");
315 }
316
317 #[test]
318 fn formatted_date_falls_back_to_created_at() {
319 let post = sample_post();
320 assert_eq!(post.formatted_date(), "2024-01-15");
321 }
322
323 #[test]
324 fn post_list_item_formatted_date_uses_published_at_when_available() {
325 let mut post = sample_post_list_item();
326 post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
327 assert_eq!(post.formatted_date(), "2024-06-01");
328 }
329
330 #[test]
331 fn post_list_item_formatted_date_falls_back_to_created_at() {
332 let post = sample_post_list_item();
333 assert_eq!(post.formatted_date(), "2024-01-15");
334 }
335
336 #[test]
337 fn post_list_item_status_label() {
338 let mut post = sample_post_list_item();
339 post.status = PostStatus::Published;
340 assert_eq!(post.status_label(), "已发布");
341 post.status = PostStatus::Draft;
342 assert_eq!(post.status_label(), "草稿");
343 }
344
345 #[test]
346 fn post_list_item_status_class_returns_non_empty() {
347 let mut post = sample_post_list_item();
348 post.status = PostStatus::Published;
349 assert_eq!(post.status_class(), "text-green-600 dark:text-green-400");
350 post.status = PostStatus::Draft;
351 assert_eq!(post.status_class(), "text-gray-400 dark:text-gray-500");
352 }
353
354 #[test]
355 fn post_list_item_status_badge_class_returns_non_empty() {
356 let mut post = sample_post_list_item();
357 post.status = PostStatus::Published;
358 assert_eq!(
359 post.status_badge_class(),
360 "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
361 );
362 post.status = PostStatus::Draft;
363 assert_eq!(
364 post.status_badge_class(),
365 "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
366 );
367 }
368
369 #[test]
370 fn post_status_serde_roundtrip() {
371 let json = serde_json::to_string(&PostStatus::Draft).unwrap();
372 assert_eq!(
373 serde_json::from_str::<PostStatus>(&json).unwrap(),
374 PostStatus::Draft
375 );
376 }
377}