| 21 | use crate::error::{AppError, AppResult}; | 21 | use crate::error::{AppError, AppResult}; |
| 22 | use crate::icons::{Icon, icon}; | 22 | use crate::icons::{Icon, icon}; |
| 23 | use crate::layout::{Chrome, page}; | 23 | use crate::layout::{Chrome, page}; |
| 24 | use crate::repo::{RepoEntry, repo_card}; | 24 | use crate::repo::{self, RepoEntry, repo_card}; |
| 25 | use crate::session::{ | 25 | use crate::session::{ |
| 26 | MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf, | 26 | MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf, |
| 27 | }; | 27 | }; |
| 71 | let body = dashboard_body(&state, &u).await?; | 71 | let body = dashboard_body(&state, &u).await?; |
| 72 | Ok((jar, page(&chrome, "Dashboard", body)).into_response()) | 72 | Ok((jar, page(&chrome, "Dashboard", body)).into_response()) |
| 73 | } else { | 73 | } else { |
| 74 | let body = explore_body(&state).await?; | 74 | let body = explore_repos_body(&state, "").await?; |
| 75 | Ok((jar, page(&chrome, "Explore", body)).into_response()) | 75 | Ok((jar, page(&chrome, "Explore", body)).into_response()) |
| 76 | } | 76 | } |
| 77 | } | 77 | } |
| 82 | MaybeUser(user): MaybeUser, | 82 | MaybeUser(user): MaybeUser, |
| 83 | jar: CookieJar, | 83 | jar: CookieJar, |
| 84 | uri: Uri, | 84 | uri: Uri, |
| | 85 | axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>, |
| 85 | ) -> AppResult<Response> { | 86 | ) -> AppResult<Response> { |
| 86 | let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string()); | 87 | let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string()); |
| 87 | let body = explore_body(&state).await?; | 88 | let body = explore_repos_body(&state, &lq.q.unwrap_or_default()).await?; |
| 88 | Ok((jar, page(&chrome, "Explore", body)).into_response()) | 89 | Ok((jar, page(&chrome, "Explore", body)).into_response()) |
| 89 | } | 90 | } |
| 90 | | 91 | |
| 91 | | 92 | |
| 92 | async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> { | 93 | pub async fn explore_users( |
| 93 | let mine = state.store.repos_by_owner(&user.id).await?; | 94 | State(state): State<AppState>, |
| 94 | let entries: Vec<RepoEntry> = mine | 95 | MaybeUser(user): MaybeUser, |
| 95 | .iter() | 96 | jar: CookieJar, |
| 96 | .map(|r| RepoEntry::owned(&user.username, r)) | 97 | uri: Uri, |
| 97 | .collect(); | 98 | axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>, |
| 98 | Ok(html! { | 99 | ) -> AppResult<Response> { |
| 99 | h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) } | 100 | let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string()); |
| 100 | (repo_card("Your repositories", "You have no repositories yet.", &entries)) | 101 | let body = explore_users_body(&state, &lq.q.unwrap_or_default()).await?; |
| 101 | }) | 102 | Ok((jar, page(&chrome, "Explore", body)).into_response()) |
| | 103 | } |
| | 104 | |
| | 105 | |
| | 106 | fn explore_subnav(active: &str) -> Markup { |
| | 107 | html! { |
| | 108 | nav class="subnav" aria-label="Explore" { |
| | 109 | a href="/explore" aria-current=[(active == "repos").then_some("page")] { |
| | 110 | (icon(Icon::Box)) span { "Repositories" } |
| | 111 | } |
| | 112 | a href="/explore/users" aria-current=[(active == "users").then_some("page")] { |
| | 113 | (icon(Icon::User)) span { "Users" } |
| | 114 | } |
| | 115 | } |
| | 116 | } |
| 102 | } | 117 | } |
| 103 | | 118 | |
| 104 | | 119 | |
| 105 | async fn explore_body(state: &AppState) -> AppResult<Markup> { | 120 | async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> { |
| | 121 | let needle = q.trim().to_lowercase(); |
| 106 | let all = state.store.list_repos().await?; | 122 | let all = state.store.list_repos().await?; |
| 107 | let public: Vec<_> = all.into_iter().filter(|r| !r.is_private).collect(); | 123 | let public: Vec<_> = all.into_iter().filter(|r| !r.is_private).collect(); |
| 108 | | 124 | |
| 109 | let mut owners: HashMap<String, String> = HashMap::new(); | 125 | let mut owners: HashMap<String, String> = HashMap::new(); |
| 110 | for repo in &public { | 126 | for repo in &public { |
| 111 | if !owners.contains_key(&repo.owner_id) { | 127 | if !owners.contains_key(&repo.owner_id) { |
| 119 | } | 135 | } |
| 120 | let entries: Vec<RepoEntry> = public | 136 | let entries: Vec<RepoEntry> = public |
| 121 | .iter() | 137 | .iter() |
| 122 | .map(|r| { | 138 | .filter_map(|r| { |
| 123 | let owner = owners.get(&r.owner_id).map_or("", String::as_str); | 139 | let owner = owners.get(&r.owner_id).map_or("", String::as_str); |
| 124 | RepoEntry::global(owner, r) | 140 | let label = format!("{owner}/{}", r.path); |
| | 141 | (needle.is_empty() || label.to_lowercase().contains(&needle)) |
| | 142 | .then(|| RepoEntry::global(owner, r)) |
| 125 | }) | 143 | }) |
| 126 | .collect(); | 144 | .collect(); |
| 127 | Ok(html! { | 145 | Ok(html! { |
| 128 | h1 { "Explore" } | 146 | (explore_subnav("repos")) |
| | 147 | form class="search-row" method="get" action="/explore" { |
| | 148 | input type="search" name="q" value=(q) placeholder="Search repositories…" |
| | 149 | aria-label="Search repositories"; |
| | 150 | button class="btn btn-primary" type="submit" { "Search" } |
| | 151 | } |
| 129 | (repo_card("Public repositories", "No public repositories.", &entries)) | 152 | (repo_card("Public repositories", "No public repositories.", &entries)) |
| 130 | }) | 153 | }) |
| 131 | } | 154 | } |
| 132 | | 155 | |
| | 156 | |
| | 157 | async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> { |
| | 158 | let needle = q.trim().to_lowercase(); |
| | 159 | let users: Vec<User> = state |
| | 160 | .store |
| | 161 | .list_users() |
| | 162 | .await? |
| | 163 | .into_iter() |
| | 164 | .filter(|u| u.disabled_at.is_none()) |
| | 165 | .filter(|u| { |
| | 166 | needle.is_empty() |
| | 167 | || u.username.to_lowercase().contains(&needle) |
| | 168 | || u.display_name |
| | 169 | .as_deref() |
| | 170 | .is_some_and(|d| d.to_lowercase().contains(&needle)) |
| | 171 | }) |
| | 172 | .collect(); |
| | 173 | Ok(html! { |
| | 174 | (explore_subnav("users")) |
| | 175 | form class="search-row" method="get" action="/explore/users" { |
| | 176 | input type="search" name="q" value=(q) placeholder="Search users…" |
| | 177 | aria-label="Search users"; |
| | 178 | button class="btn btn-primary" type="submit" { "Search" } |
| | 179 | } |
| | 180 | (user_card(&users)) |
| | 181 | }) |
| | 182 | } |
| | 183 | |
| | 184 | |
| | 185 | fn user_card(users: &[User]) -> Markup { |
| | 186 | html! { |
| | 187 | section class="listing" { |
| | 188 | h2 { "Users" } |
| | 189 | @if users.is_empty() { |
| | 190 | div class="card" { p class="muted" { "No users." } } |
| | 191 | } @else { |
| | 192 | ul class="repo-list card" { |
| | 193 | @for u in users { |
| | 194 | li { |
| | 195 | a class="user-row" href={ "/" (u.username) } { |
| | 196 | img class="avatar" src={ "/avatar/" (u.username) } alt=""; |
| | 197 | span class="user-row-main" { |
| | 198 | span class="user-row-name" { |
| | 199 | (u.display_name.as_deref().unwrap_or(&u.username)) |
| | 200 | } |
| | 201 | span class="user-row-meta muted" { |
| | 202 | "@" (u.username) " · Joined on " (repo::fmt_joined(u.created_at)) |
| | 203 | } |
| | 204 | } |
| | 205 | } |
| | 206 | } |
| | 207 | } |
| | 208 | } |
| | 209 | } |
| | 210 | } |
| | 211 | } |
| | 212 | } |
| | 213 | |
| | 214 | |
| | 215 | async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> { |
| | 216 | let mine = state.store.repos_by_owner(&user.id).await?; |
| | 217 | let entries: Vec<RepoEntry> = mine |
| | 218 | .iter() |
| | 219 | .map(|r| RepoEntry::owned(&user.username, r)) |
| | 220 | .collect(); |
| | 221 | Ok(html! { |
| | 222 | h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) } |
| | 223 | (repo_card("Your repositories", "You have no repositories yet.", &entries)) |
| | 224 | }) |
| | 225 | } |
| | 226 | |
| 133 | | 227 | |
| 134 | #[derive(Debug, Deserialize)] | 228 | #[derive(Debug, Deserialize)] |
| 135 | pub struct LoginForm { | 229 | pub struct LoginForm { |