Krolyxon
Commit 55c1225

ferreniumsrchandlersindex.rs

156 lines · 4.1 KB · Rust

1//! `/` — repository index.
2
3use std::sync::Arc;
4
5use askama::Template;
6use axum::extract::{Query, State};
7use axum::http::StatusCode;
8use axum::response::Response;
9use serde::Deserialize;
10
11use super::{Layout, render};
12use crate::index::Repo;
13use crate::render::{encode_path, time};
14use crate::state::AppState;
15
16/// Repos pushed within this window get the activity dot.
17const FRESH_SECS: i64 = 24 * 3600;
18
19#[derive(Debug, Default, Deserialize)]
20pub struct IndexQuery {
21 #[serde(default)]
22 q: String,
23 #[serde(default)]
24 sort: String,
25}
26
27pub struct Row {
28 pub name: String,
29 pub url: String,
30 pub description: Option<String>,
31 pub age: Option<String>,
32 pub iso: Option<String>,
33 pub fresh: bool,
34 /// Lowercased text the client-side filter matches against.
35 pub search: String,
36}
37
38pub struct Section {
39 pub name: Option<String>,
40 pub rows: Vec<Row>,
41}
42
43#[derive(Template)]
44#[template(path = "index.html")]
45pub struct IndexPage {
46 pub layout: Layout,
47 pub description: String,
48 /// Visible repositories in total.
49 pub total: usize,
50 /// Repositories matching the filter.
51 pub shown: usize,
52 pub q: String,
53 pub by_activity: bool,
54 pub sort_name_url: String,
55 pub sort_activity_url: String,
56 pub sections: Vec<Section>,
57}
58
59pub async fn index(
60 State(state): State<Arc<AppState>>,
61 Query(query): Query<IndexQuery>,
62) -> Response {
63 let config = state.config.load();
64 let index = state.index.load();
65 let by_activity = query.sort == "activity";
66 let q = query.q.trim().to_string();
67 let terms: Vec<String> = q.split_whitespace().map(str::to_lowercase).collect();
68 let now = time::now();
69 let base = &config.server.base_path;
70
71 let mut repos: Vec<&Arc<Repo>> = index
72 .repos
73 .values()
74 .filter(|repo| {
75 let text = search_text(repo);
76 terms.iter().all(|t| text.contains(t.as_str()))
77 })
78 .collect();
79 let shown = repos.len();
80
81 if by_activity {
82 repos.sort_by(|a, b| {
83 b.meta
84 .last_activity
85 .cmp(&a.meta.last_activity)
86 .then_with(|| a.name.cmp(&b.name))
87 });
88 } else {
89 repos.sort_by_cached_key(|r| {
90 (
91 r.meta.section.clone().map(|s| s.to_lowercase()),
92 r.name.to_lowercase(),
93 )
94 });
95 }
96
97 let row = |repo: &Arc<Repo>| Row {
98 name: repo.name.clone(),
99 url: format!("{base}{}", encode_path(&repo.name)),
100 description: repo.meta.description.clone(),
101 age: repo.meta.last_activity.map(|t| time::relative(t, now)),
102 iso: repo.meta.last_activity.map(time::iso),
103 fresh: repo
104 .meta
105 .last_activity
106 .is_some_and(|t| now - t < FRESH_SECS),
107 search: search_text(repo),
108 };
109
110 let mut sections: Vec<Section> = Vec::new();
111 for repo in repos {
112 // Sections only apply to the name ordering; activity is one list.
113 let section = if by_activity {
114 None
115 } else {
116 repo.meta.section.clone()
117 };
118 match sections.last_mut() {
119 Some(last) if last.name == section => last.rows.push(row(repo)),
120 _ => sections.push(Section {
121 name: section,
122 rows: vec![row(repo)],
123 }),
124 }
125 }
126
127 let sort_url = |sort: &str| {
128 let mut url = format!("{base}?sort={sort}");
129 if !q.is_empty() {
130 url.push_str("&q=");
131 url.push_str(&encode_path(&q));
132 }
133 url
134 };
135 let page = IndexPage {
136 layout: Layout::new(&state, &config, None),
137 description: config.site.description.clone(),
138 total: index.repos.len(),
139 shown,
140 sort_name_url: sort_url("name"),
141 sort_activity_url: sort_url("activity"),
142 q,
143 by_activity,
144 sections,
145 };
146 render(StatusCode::OK, &page)
147}
148
149fn search_text(repo: &Repo) -> String {
150 let mut text = repo.name.to_lowercase();
151 if let Some(d) = &repo.meta.description {
152 text.push(' ');
153 text.push_str(&d.to_lowercase());
154 }
155 text
156}