yggdrasil/api/posts/
archive.rs1use crate::api::error::ApiError;
3use leptos::prelude::*;
4
5use crate::models::archive::ArchivePost;
6
7pub const ARCHIVE_PAGE_SIZE: usize = 100;
8
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct ArchiveResponse {
11 pub posts: Vec<ArchivePost>,
12 pub total: i64,
13}
14
15#[server(ListArchivePosts, endpoint = "list_archive_posts", input = leptos::server_fn::codec::Json)]
16pub async fn list_archive_posts(page: i32) -> Result<ArchiveResponse, ApiError> {
17 #[cfg(feature = "server")]
18 {
19 use crate::api::error::AppError;
20 let page = page.clamp(1, 10_000);
21 if let Some(response) = crate::cache::get_archive(page).await {
22 return Ok(response);
23 }
24 let client = crate::db::pool::get_conn()
25 .await
26 .map_err(AppError::db_conn)?;
27 let total: i64 = client
28 .query_one(
29 "SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL",
30 &[],
31 )
32 .await
33 .map_err(AppError::query)?
34 .get(0);
35 let rows = client.query(
36 "SELECT p.id, p.title, p.slug, COALESCE(p.published_at, p.created_at) AS published_at,
37 p.reading_time, COALESCE(array_agg(t.name ORDER BY t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags
38 FROM posts p LEFT JOIN post_tags pt ON pt.post_id = p.id LEFT JOIN tags t ON t.id = pt.tag_id
39 WHERE p.status = 'published' AND p.deleted_at IS NULL
40 GROUP BY p.id ORDER BY COALESCE(p.published_at, p.created_at) DESC, p.id DESC LIMIT $1 OFFSET $2",
41 &[&(ARCHIVE_PAGE_SIZE as i64), &((i64::from(page) - 1) * ARCHIVE_PAGE_SIZE as i64)],
42 ).await.map_err(AppError::query)?;
43 let posts = rows
44 .iter()
45 .map(|row| ArchivePost {
46 id: row.get("id"),
47 title: row.get("title"),
48 slug: row.get("slug"),
49 published_at: row.get("published_at"),
50 reading_time: row.get::<_, i32>("reading_time").max(1) as u32,
51 tags: row.get("tags"),
52 })
53 .collect();
54 let response = ArchiveResponse { posts, total };
55 crate::cache::set_archive(page, response.clone()).await;
56 Ok(response)
57 }
58 #[cfg(not(feature = "server"))]
59 {
60 let _ = page;
61 Ok(ArchiveResponse {
62 posts: vec![],
63 total: 0,
64 })
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
70pub struct YearCount {
71 pub year: i32,
72 pub count: i64,
73}
74
75#[server(PostsPerYear, endpoint = "posts_per_year", input = leptos::server_fn::codec::Json)]
78pub async fn posts_per_year() -> Result<Vec<YearCount>, ApiError> {
79 #[cfg(feature = "server")]
80 {
81 use crate::api::error::AppError;
82 if let Some(cached) = crate::cache::get_year_counts().await {
83 return Ok(cached);
84 }
85 let client = crate::db::pool::get_conn()
86 .await
87 .map_err(AppError::db_conn)?;
88 let rows = client
89 .query(
90 "SELECT EXTRACT(YEAR FROM COALESCE(published_at, created_at))::int AS year, COUNT(*)
91 FROM posts WHERE status = 'published' AND deleted_at IS NULL
92 GROUP BY 1 ORDER BY 1",
93 &[],
94 )
95 .await
96 .map_err(AppError::query)?;
97 let counts = fill_years(rows.iter().map(|r| (r.get(0), r.get(1))).collect());
98 crate::cache::set_year_counts(counts.clone()).await;
99 Ok(counts)
100 }
101 #[cfg(not(feature = "server"))]
102 {
103 Ok(Vec::new())
104 }
105}
106
107#[cfg(any(feature = "server", test))]
109fn fill_years(counts: Vec<(i32, i64)>) -> Vec<YearCount> {
110 let (Some(&(first, _)), Some(&(last, _))) = (counts.first(), counts.last()) else {
111 return Vec::new();
112 };
113 (first..=last)
114 .map(|year| YearCount {
115 year,
116 count: counts
117 .iter()
118 .find(|(y, _)| *y == year)
119 .map_or(0, |(_, n)| *n),
120 })
121 .collect()
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn missing_years_are_filled_with_zero() {
130 let years = fill_years(vec![(2020, 1), (2022, 12), (2023, 4)]);
131 let shape: Vec<_> = years.iter().map(|y| (y.year, y.count)).collect();
132 assert_eq!(shape, [(2020, 1), (2021, 0), (2022, 12), (2023, 4)]);
133 assert!(fill_years(Vec::new()).is_empty());
134 }
135}