forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
9.2 KiB
1//! Page: public repo listing at `/explore`. Only public repos are listed, even for their owners
2//! (private repos never appear here). A search box filters by name or description, three sort
3//! orders are offered, and 30 repos are shown per page. `explore` is a reserved username, so this
4//! route cannot collide with a profile.
5
6use forge_core::store::RepoSort;
7use topcoat::Result;
8use topcoat::context::{Cx, app_context};
9use topcoat::router::{page, query_params};
10use topcoat::view::{View, component, view};
11
12use crate::auth::current_user;
13use crate::i18n::orgs::{self as g, K};
14use crate::i18n::{self, Lang};
15use crate::state::AppState;
16use crate::views::{enc, now_unix, relative_time, repo_href, shell};
17
18const PER_PAGE: u64 = 30;
19/// Longest search text kept, in characters.
20const MAX_QUERY_CHARS: usize = 100;
21
22/// Sort keys in the order their links are shown, with the text key of each link. The first one is
23/// the default.
24const SORTS: [(&str, K); 3] = [
25 ("updated", K::SortRecentlyUpdated),
26 ("stars", K::SortMostStars),
27 ("name", K::Name),
28];
29
30#[query_params(error = bad_request)]
31struct ExploreQuery {
32 q: Option<String>,
33 sort: Option<String>,
34 page: Option<String>,
35}
36
37/// One listed repo, already formatted for display.
38struct ExploreRow {
39 href: String,
40 title: String,
41 description: String,
42 stars: u64,
43 updated: String,
44}
45
46/// One sort link: its label, target and whether it is the active order.
47struct SortLink {
48 label: &'static str,
49 href: String,
50 current: bool,
51}
52
53/// `/explore` with the given search, sort key and page. An empty search is left out.
54fn explore_href(q: &str, sort: &str, page: u64) -> String {
55 let mut href = String::from("/explore?");
56 if !q.is_empty() {
57 href.push_str("q=");
58 href.push_str(&enc(q));
59 href.push('&');
60 }
61 href.push_str(&format!("sort={sort}&page={page}"));
62 href
63}
64
65/// Maps the `sort` query value to a key from `SORTS`; anything unknown means the default.
66fn sort_key(raw: Option<&str>) -> &'static str {
67 SORTS
68 .iter()
69 .map(|(key, _)| *key)
70 .find(|key| Some(*key) == raw)
71 .unwrap_or(SORTS[0].0)
72}
73
74/// Unix seconds of a stored RFC 3339 stamp. An unreadable stamp gives 0.
75fn stamp_unix(stamp: &str) -> i64 {
76 time::OffsetDateTime::parse(stamp, &time::format_description::well_known::Rfc3339)
77 .map_or(0, |t| t.unix_timestamp())
78}
79
80#[page("/explore")]
81async fn explore_page(cx: &Cx) -> Result<impl View> {
82 let query = query_params::<ExploreQuery>(cx)?;
83 let q: String = query
84 .q
85 .as_deref()
86 .unwrap_or("")
87 .trim()
88 .chars()
89 .take(MAX_QUERY_CHARS)
90 .collect();
91 let sort = sort_key(query.sort.as_deref());
92 let page = query
93 .page
94 .as_deref()
95 .and_then(|p| p.parse::<u64>().ok())
96 .unwrap_or(1)
97 .max(1);
98 let viewer = current_user(cx).await?;
99 let lang = i18n::lang(cx, viewer.as_ref().map(|(u, _)| u));
100 let state = app_context::<AppState>(cx);
101
102 let sort_kind = match sort {
103 "stars" => RepoSort::MostStars,
104 "name" => RepoSort::Name,
105 _ => RepoSort::RecentlyUpdated,
106 };
107 let search = (!q.is_empty()).then_some(q.as_str());
108 let (listed, total) = state
109 .store
110 .list_public_repos(search, sort_kind, page, PER_PAGE)
111 .await?;
112
113 let now = now_unix();
114 let rows: Vec<ExploreRow> = listed
115 .into_iter()
116 .map(|(repo, stars, updated)| ExploreRow {
117 href: repo_href(&repo.owner_name, &repo.name, "", ""),
118 title: format!("{}/{}", repo.owner_name, repo.name),
119 description: repo.description,
120 stars,
121 updated: relative_time(lang, stamp_unix(&updated), now),
122 })
123 .collect();
124 let orgs: Vec<String> = state
125 .store
126 .list_orgs()
127 .await?
128 .into_iter()
129 .map(|org| org.username)
130 .collect();
131 let pages = total.div_ceil(PER_PAGE).max(1);
132 let sort_links: Vec<SortLink> = SORTS
133 .iter()
134 .map(|(key, label)| SortLink {
135 label: g::t(lang, *label),
136 href: explore_href(&q, key, 1),
137 current: *key == sort,
138 })
139 .collect();
140 let prev = (page > 1).then(|| explore_href(&q, sort, page - 1));
141 let next = (page < pages).then(|| explore_href(&q, sort, page + 1));
142 let title = g::t(lang, K::Explore);
143
144 Ok(view! { cx =>
145 shell(title: title, viewer: viewer.as_ref(),
146 explore_body(q: q, sort: sort, sort_links: sort_links, rows: rows,
147 total: total, page: page, pages: pages, prev: prev, next: next, orgs: orgs, lang: lang))
148 })
149}
150
151#[component]
152async fn explore_body(
153 q: String,
154 sort: &'static str,
155 sort_links: Vec<SortLink>,
156 rows: Vec<ExploreRow>,
157 total: u64,
158 page: u64,
159 pages: u64,
160 prev: Option<String>,
161 next: Option<String>,
162 orgs: Vec<String>,
163 lang: Lang,
164) -> Result<impl View> {
165 let empty = rows.is_empty();
166 let has_orgs = !orgs.is_empty();
167 Ok(view! {
168 <section class="explore">
169 <h1>(g::t(lang, K::Explore))</h1>
170 <p class="muted">(g::t(lang, K::PublicReposIntro))</p>
171 if has_orgs {
172 <section class="explore-orgs">
173 <h2>(g::t(lang, K::Organizations))</h2>
174 <ul class="item-list">
175 for org in orgs.iter() {
176 <li><a href=(format!("/{}", org.as_str()))>(org.as_str())</a></li>
177 }
178 </ul>
179 </section>
180 }
181 <form method="get" action="/explore" class="explore-search" role="search">
182 <input type="search" name="q" value=(q.as_str())
183 placeholder=(g::t(lang, K::SearchPublicRepos)) aria-label=(g::t(lang, K::SearchPublicRepos))>
184 <input type="hidden" name="sort" value=(sort)>
185 <button type="submit">(g::t(lang, K::Search))</button>
186 </form>
187 <nav class="explore-sort" aria-label=(g::t(lang, K::SortNav))>
188 <span class="muted small">(g::t(lang, K::SortBy))</span>
189 for link in sort_links.into_iter() {
190 if link.current {
191 <span class="current" aria-current="true">(link.label)</span>
192 } else {
193 <a href=(link.href.as_str())>(link.label)</a>
194 }
195 }
196 </nav>
197 <p class="muted small">(g::repo_count(lang, total))</p>
198 if empty {
199 <p class="muted">(g::t(lang, K::NoPublicReposMatch))</p>
200 } else {
201 <ul class="repo-list">
202 for row in rows.into_iter() {
203 <li class="repo-item">
204 <a class="repo-name" href=(row.href.as_str())>(row.title.as_str())</a>
205 if !row.description.is_empty() {
206 <p class="muted">(row.description.as_str())</p>
207 }
208 <p class="muted small">
209 "\u{2605} " (row.stars.to_string())
210 (g::updated_text(lang, row.updated.as_str()))
211 </p>
212 </li>
213 }
214 </ul>
215 }
216 if pages > 1 {
217 <nav class="pager" aria-label=(g::t(lang, K::Pages))>
218 if let Some(href) = prev {
219 <a href=(href.as_str())>(g::t(lang, K::Previous))</a>
220 }
221 <span class="muted small">(g::page_of(lang, page, pages))</span>
222 if let Some(href) = next {
223 <a href=(href.as_str())>(g::t(lang, K::Next))</a>
224 }
225 </nav>
226 }
227 </section>
228 })
229}
230
231#[cfg(test)]
232mod tests {
233 use topcoat::router::request::Request;
234 use topcoat::router::{Body, Router, to_bytes};
235
236 async fn get_with_language(router: &Router, uri: &str, language: &str) -> String {
237 let request = Request::builder()
238 .method("GET")
239 .uri(uri)
240 .header("accept-language", language)
241 .body(Body::empty())
242 .unwrap();
243 let res = router.handle(request).await;
244 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
245 String::from_utf8_lossy(&bytes).into_owned()
246 }
247
248 #[tokio::test]
249 async fn turkish_viewer_gets_the_turkish_explore_page() {
250 let dir = tempfile::tempdir().expect("tempdir");
251 let path = dir.path().to_path_buf();
252 let state = crate::test_support::test_state(&path).await;
253 let router = crate::test_support::app_router(state);
254
255 let body = get_with_language(&router, "/explore", "tr").await;
256 assert!(body.contains("Keşfet"), "heading in Turkish: {body}");
257 assert!(body.contains("Bu forge üzerindeki herkese açık depolar."));
258 assert!(body.contains("Herkese açık depolarda ara"));
259 assert!(!body.contains("Public repositories on this forge."));
260
261 let body = get_with_language(&router, "/explore", "en").await;
262 assert!(body.contains("Public repositories on this forge."));
263 }
264}