//! Page: public repo listing at `/explore`. Only public repos are listed, even for their owners //! (private repos never appear here). A search box filters by name or description, three sort //! orders are offered, and 30 repos are shown per page. `explore` is a reserved username, so this //! route cannot collide with a profile. use forge_core::store::RepoSort; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::{page, query_params}; use topcoat::view::{View, component, view}; use crate::auth::current_user; use crate::i18n::orgs::{self as g, K}; use crate::i18n::{self, Lang}; use crate::state::AppState; use crate::views::{enc, now_unix, relative_time, repo_href, shell}; const PER_PAGE: u64 = 30; /// Longest search text kept, in characters. const MAX_QUERY_CHARS: usize = 100; /// Sort keys in the order their links are shown, with the text key of each link. The first one is /// the default. const SORTS: [(&str, K); 3] = [ ("updated", K::SortRecentlyUpdated), ("stars", K::SortMostStars), ("name", K::Name), ]; #[query_params(error = bad_request)] struct ExploreQuery { q: Option, sort: Option, page: Option, } /// One listed repo, already formatted for display. struct ExploreRow { href: String, title: String, description: String, stars: u64, updated: String, } /// One sort link: its label, target and whether it is the active order. struct SortLink { label: &'static str, href: String, current: bool, } /// `/explore` with the given search, sort key and page. An empty search is left out. fn explore_href(q: &str, sort: &str, page: u64) -> String { let mut href = String::from("/explore?"); if !q.is_empty() { href.push_str("q="); href.push_str(&enc(q)); href.push('&'); } href.push_str(&format!("sort={sort}&page={page}")); href } /// Maps the `sort` query value to a key from `SORTS`; anything unknown means the default. fn sort_key(raw: Option<&str>) -> &'static str { SORTS .iter() .map(|(key, _)| *key) .find(|key| Some(*key) == raw) .unwrap_or(SORTS[0].0) } /// Unix seconds of a stored RFC 3339 stamp. An unreadable stamp gives 0. fn stamp_unix(stamp: &str) -> i64 { time::OffsetDateTime::parse(stamp, &time::format_description::well_known::Rfc3339) .map_or(0, |t| t.unix_timestamp()) } #[page("/explore")] async fn explore_page(cx: &Cx) -> Result { let query = query_params::(cx)?; let q: String = query .q .as_deref() .unwrap_or("") .trim() .chars() .take(MAX_QUERY_CHARS) .collect(); let sort = sort_key(query.sort.as_deref()); let page = query .page .as_deref() .and_then(|p| p.parse::().ok()) .unwrap_or(1) .max(1); let viewer = current_user(cx).await?; let lang = i18n::lang(cx, viewer.as_ref().map(|(u, _)| u)); let state = app_context::(cx); let sort_kind = match sort { "stars" => RepoSort::MostStars, "name" => RepoSort::Name, _ => RepoSort::RecentlyUpdated, }; let search = (!q.is_empty()).then_some(q.as_str()); let (listed, total) = state .store .list_public_repos(search, sort_kind, page, PER_PAGE) .await?; let now = now_unix(); let rows: Vec = listed .into_iter() .map(|(repo, stars, updated)| ExploreRow { href: repo_href(&repo.owner_name, &repo.name, "", ""), title: format!("{}/{}", repo.owner_name, repo.name), description: repo.description, stars, updated: relative_time(lang, stamp_unix(&updated), now), }) .collect(); let orgs: Vec = state .store .list_orgs() .await? .into_iter() .map(|org| org.username) .collect(); let pages = total.div_ceil(PER_PAGE).max(1); let sort_links: Vec = SORTS .iter() .map(|(key, label)| SortLink { label: g::t(lang, *label), href: explore_href(&q, key, 1), current: *key == sort, }) .collect(); let prev = (page > 1).then(|| explore_href(&q, sort, page - 1)); let next = (page < pages).then(|| explore_href(&q, sort, page + 1)); let title = g::t(lang, K::Explore); Ok(view! { cx => shell(title: title, viewer: viewer.as_ref(), explore_body(q: q, sort: sort, sort_links: sort_links, rows: rows, total: total, page: page, pages: pages, prev: prev, next: next, orgs: orgs, lang: lang)) }) } #[component] async fn explore_body( q: String, sort: &'static str, sort_links: Vec, rows: Vec, total: u64, page: u64, pages: u64, prev: Option, next: Option, orgs: Vec, lang: Lang, ) -> Result { let empty = rows.is_empty(); let has_orgs = !orgs.is_empty(); Ok(view! {

(g::t(lang, K::Explore))

(g::t(lang, K::PublicReposIntro))

if has_orgs {

(g::t(lang, K::Organizations))

}

(g::repo_count(lang, total))

if empty {

(g::t(lang, K::NoPublicReposMatch))

} else {
    for row in rows.into_iter() {
  • (row.title.as_str()) if !row.description.is_empty() {

    (row.description.as_str())

    }

    "\u{2605} " (row.stars.to_string()) (g::updated_text(lang, row.updated.as_str()))

  • }
} if pages > 1 { }
}) } #[cfg(test)] mod tests { use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; async fn get_with_language(router: &Router, uri: &str, language: &str) -> String { let request = Request::builder() .method("GET") .uri(uri) .header("accept-language", language) .body(Body::empty()) .unwrap(); let res = router.handle(request).await; let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); String::from_utf8_lossy(&bytes).into_owned() } #[tokio::test] async fn turkish_viewer_gets_the_turkish_explore_page() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().to_path_buf(); let state = crate::test_support::test_state(&path).await; let router = crate::test_support::app_router(state); let body = get_with_language(&router, "/explore", "tr").await; assert!(body.contains("Keşfet"), "heading in Turkish: {body}"); assert!(body.contains("Bu forge üzerindeki herkese açık depolar.")); assert!(body.contains("Herkese açık depolarda ara")); assert!(!body.contains("Public repositories on this forge.")); let body = get_with_language(&router, "/explore", "en").await; assert!(body.contains("Public repositories on this forge.")); } }