| 78 | pub(crate) access: auth::Access, | 78 | pub(crate) access: auth::Access, |
| 79 | | 79 | |
| 80 | pub(crate) viewer_id: Option<String>, | 80 | pub(crate) viewer_id: Option<String>, |
| | 81 | |
| | 82 | pub(crate) bookmarked: bool, |
| 81 | | 83 | |
| 82 | pub(crate) fork_parent: Option<(String, String)>, | 84 | pub(crate) fork_parent: Option<(String, String)>, |
| 83 | } | 85 | } |
| 153 | Some(pid) => resolve_fork_parent(state, pid).await, | 155 | Some(pid) => resolve_fork_parent(state, pid).await, |
| 154 | None => None, | 156 | None => None, |
| 155 | }; | 157 | }; |
| | 158 | let bookmarked = match viewer { |
| | 159 | Some(u) => state |
| | 160 | .store |
| | 161 | .is_bookmarked(&u.id, &repo.id) |
| | 162 | .await |
| | 163 | .unwrap_or(false), |
| | 164 | None => false, |
| | 165 | }; |
| 156 | Ok(Some(RepoCtx { | 166 | Ok(Some(RepoCtx { |
| 157 | owner, | 167 | owner, |
| 158 | repo, | 168 | repo, |
| 159 | access, | 169 | access, |
| 160 | viewer_id: viewer.map(|u| u.id.clone()), | 170 | viewer_id: viewer.map(|u| u.id.clone()), |
| | 171 | bookmarked, |
| 161 | fork_parent, | 172 | fork_parent, |
| 162 | })) | 173 | })) |
| 163 | } | 174 | } |
| 180 | | 191 | |
| 181 | #[serde(default)] | 192 | #[serde(default)] |
| 182 | pub q: Option<String>, | 193 | pub q: Option<String>, |
| | 194 | |
| | 195 | #[serde(default)] |
| | 196 | pub tab: Option<String>, |
| 183 | } | 197 | } |
| 184 | | 198 | |
| 185 | | 199 | |
| 303 | let Some(owner) = state.store.user_by_username(&owner_name).await? else { | 317 | let Some(owner) = state.store.user_by_username(&owner_name).await? else { |
| 304 | return Err(AppError::NotFound); | 318 | return Err(AppError::NotFound); |
| 305 | }; | 319 | }; |
| 306 | let repos = visible_repos(&state, &owner, viewer.as_ref()).await?; | 320 | let tab = match lq.tab.as_deref() { |
| 307 | let groups = state.store.groups_by_owner(&owner.id).await?; | 321 | Some("groups") => ProfileTab::Groups, |
| 308 | | 322 | Some("bookmarks") => ProfileTab::Bookmarks, |
| | 323 | _ => ProfileTab::Repos, |
| | 324 | }; |
| 309 | let q = lq.q.unwrap_or_default(); | 325 | let q = lq.q.unwrap_or_default(); |
| 310 | let needle = q.trim().to_lowercase(); | 326 | let needle = q.trim().to_lowercase(); |
| 311 | | 327 | let base = format!("/{}", owner.username); |
| 312 | | | |
| 313 | | | |
| 314 | let top_groups: Vec<_> = groups.iter().filter(|g| !g.path.contains('/')).collect(); | | |
| 315 | let entries: Vec<RepoEntry> = repos | | |
| 316 | .iter() | | |
| 317 | .filter(|r| { | | |
| 318 | let matches = needle.is_empty() || r.path.to_lowercase().contains(&needle); | | |
| 319 | matches && (!needle.is_empty() || !r.path.contains('/')) | | |
| 320 | }) | | |
| 321 | .map(|r| RepoEntry::owned(&owner.username, r)) | | |
| 322 | .collect(); | | |
| 323 | | 328 | |
| 324 | let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string()); | 329 | let content = match tab { |
| 325 | let body = html! { | 330 | ProfileTab::Repos => { |
| 326 | div class="profile-layout" { | 331 | let repos = visible_repos(&state, &owner, viewer.as_ref()).await?; |
| 327 | aside class="profile-sidebar" { (profile_sidebar(&owner)) } | 332 | |
| 328 | div class="profile-main" { | 333 | let entries: Vec<RepoEntry> = repos |
| | 334 | .iter() |
| | 335 | .filter(|r| { |
| | 336 | let matches = needle.is_empty() || r.path.to_lowercase().contains(&needle); |
| | 337 | matches && (!needle.is_empty() || !r.path.contains('/')) |
| | 338 | }) |
| | 339 | .map(|r| RepoEntry::owned(&owner.username, r)) |
| | 340 | .collect(); |
| | 341 | html! { |
| 329 | form class="search-row" method="get" { | 342 | form class="search-row" method="get" { |
| 330 | input type="search" name="q" value=(q) | 343 | input type="search" name="q" value=(q) |
| 331 | placeholder="Search repositories…" aria-label="Search repositories"; | 344 | placeholder="Search repositories…" aria-label="Search repositories"; |
| 332 | button class="btn btn-primary" type="submit" { "Search" } | 345 | button class="btn btn-primary" type="submit" { "Search" } |
| 333 | } | 346 | } |
| 334 | @if !top_groups.is_empty() { | 347 | (repo_card("", "No repositories.", &entries)) |
| 335 | section class="listing" { | 348 | } |
| 336 | h2 { "Groups" } | 349 | } |
| | 350 | ProfileTab::Groups => { |
| | 351 | let groups = state.store.groups_by_owner(&owner.id).await?; |
| | 352 | let top_groups: Vec<_> = groups.iter().filter(|g| !g.path.contains('/')).collect(); |
| | 353 | html! { |
| | 354 | section class="listing" { |
| | 355 | @if top_groups.is_empty() { |
| | 356 | div class="card" { p class="muted" { "No groups." } } |
| | 357 | } @else { |
| 337 | ul class="repo-list card" { | 358 | ul class="repo-list card" { |
| 338 | @for group in &top_groups { | 359 | @for group in &top_groups { |
| 339 | li { | 360 | li { |
| 347 | } | 368 | } |
| 348 | } | 369 | } |
| 349 | } | 370 | } |
| 350 | (repo_card("Repositories", "No repositories.", &entries)) | 371 | } |
| | 372 | } |
| | 373 | ProfileTab::Bookmarks => { |
| | 374 | let bookmarked = state.store.bookmarked_repos(&owner.id).await?; |
| | 375 | let visible = filter_readable(&state, bookmarked, viewer.as_ref()).await?; |
| | 376 | let entries: Vec<RepoEntry> = visible |
| | 377 | .iter() |
| | 378 | .map(|r| { |
| | 379 | |
| | 380 | RepoEntry::global(&r.owner_id, r) |
| | 381 | }) |
| | 382 | .collect(); |
| | 383 | |
| | 384 | let entries = resolve_bookmark_labels(&state, &visible, entries).await; |
| | 385 | html! { (repo_card("", "No bookmarks yet.", &entries)) } |
| | 386 | } |
| | 387 | }; |
| | 388 | |
| | 389 | let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string()); |
| | 390 | let ptab = |t: ProfileTab, key: &str, label: &str| { |
| | 391 | html! { |
| | 392 | a href=(format!("{base}?tab={key}")) aria-current=[(tab == t).then_some("page")] { (label) } |
| | 393 | } |
| | 394 | }; |
| | 395 | let body = html! { |
| | 396 | div class="profile-layout" { |
| | 397 | aside class="profile-sidebar" { (profile_sidebar(&owner)) } |
| | 398 | div class="profile-main" { |
| | 399 | nav class="tabs profile-tabs" { |
| | 400 | (ptab(ProfileTab::Repos, "repos", "Repositories")) |
| | 401 | (ptab(ProfileTab::Groups, "groups", "Groups")) |
| | 402 | (ptab(ProfileTab::Bookmarks, "bookmarks", "Bookmarks")) |
| | 403 | } |
| | 404 | (content) |
| 351 | } | 405 | } |
| 352 | } | 406 | } |
| 353 | }; | 407 | }; |
| 354 | Ok((jar, page(&chrome, &owner.username, body)).into_response()) | 408 | Ok((jar, page(&chrome, &owner.username, body)).into_response()) |
| 355 | } | 409 | } |
| 356 | | 410 | |
| | 411 | |
| | 412 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| | 413 | enum ProfileTab { |
| | 414 | Repos, |
| | 415 | Groups, |
| | 416 | Bookmarks, |
| | 417 | } |
| | 418 | |
| | 419 | |
| | 420 | async fn filter_readable( |
| | 421 | state: &AppState, |
| | 422 | repos: Vec<Repo>, |
| | 423 | viewer: Option<&User>, |
| | 424 | ) -> AppResult<Vec<Repo>> { |
| | 425 | let viewer_id = viewer.map(|v| auth::Viewer { |
| | 426 | id: v.id.clone(), |
| | 427 | is_admin: v.is_admin, |
| | 428 | }); |
| | 429 | let mut out = Vec::new(); |
| | 430 | for repo in repos { |
| | 431 | let collab = match viewer { |
| | 432 | Some(v) => state |
| | 433 | .store |
| | 434 | .collaborator_permission(&repo.id, &v.id) |
| | 435 | .await? |
| | 436 | .and_then(|p| auth::Permission::parse(&p)), |
| | 437 | None => None, |
| | 438 | }; |
| | 439 | let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous()); |
| | 440 | if access >= auth::Access::Read { |
| | 441 | out.push(repo); |
| | 442 | } |
| | 443 | } |
| | 444 | Ok(out) |
| | 445 | } |
| | 446 | |
| | 447 | |
| | 448 | async fn resolve_bookmark_labels( |
| | 449 | state: &AppState, |
| | 450 | repos: &[Repo], |
| | 451 | mut entries: Vec<RepoEntry>, |
| | 452 | ) -> Vec<RepoEntry> { |
| | 453 | let mut names: std::collections::HashMap<String, String> = std::collections::HashMap::new(); |
| | 454 | for (repo, entry) in repos.iter().zip(entries.iter_mut()) { |
| | 455 | let owner = if let Some(n) = names.get(&repo.owner_id) { |
| | 456 | n.clone() |
| | 457 | } else { |
| | 458 | let n = state |
| | 459 | .store |
| | 460 | .user_by_id(&repo.owner_id) |
| | 461 | .await |
| | 462 | .ok() |
| | 463 | .flatten() |
| | 464 | .map_or_else(|| repo.owner_id.clone(), |u| u.username); |
| | 465 | names.insert(repo.owner_id.clone(), n.clone()); |
| | 466 | n |
| | 467 | }; |
| | 468 | entry.href = format!("/{owner}/{}", repo.path); |
| | 469 | entry.label = format!("{owner}/{}", repo.path); |
| | 470 | } |
| | 471 | entries |
| | 472 | } |
| | 473 | |
| 357 | | 474 | |
| 358 | pub async fn dispatch( | 475 | pub async fn dispatch( |
| 359 | State(state): State<AppState>, | 476 | State(state): State<AppState>, |
| 1618 | Ok(repo) | 1735 | Ok(repo) |
| 1619 | } | 1736 | } |
| 1620 | | 1737 | |
| | 1738 | |
| | 1739 | |
| | 1740 | |
| | 1741 | pub async fn bookmark_toggle( |
| | 1742 | State(state): State<AppState>, |
| | 1743 | MaybeUser(viewer): MaybeUser, |
| | 1744 | Path(id): Path<String>, |
| | 1745 | ) -> AppResult<Response> { |
| | 1746 | let Some(user) = viewer.clone() else { |
| | 1747 | return Ok(Redirect::to("/login").into_response()); |
| | 1748 | }; |
| | 1749 | let repo = require_readable_repo(&state, viewer.as_ref(), &id).await?; |
| | 1750 | if state.store.is_bookmarked(&user.id, &repo.id).await? { |
| | 1751 | state.store.remove_bookmark(&user.id, &repo.id).await?; |
| | 1752 | } else { |
| | 1753 | state.store.add_bookmark(&user.id, &repo.id).await?; |
| | 1754 | } |
| | 1755 | let owner = state |
| | 1756 | .store |
| | 1757 | .user_by_id(&repo.owner_id) |
| | 1758 | .await? |
| | 1759 | .map_or_else(|| repo.owner_id.clone(), |u| u.username); |
| | 1760 | Ok(Redirect::to(&format!("/{owner}/{}", repo.path)).into_response()) |
| | 1761 | } |
| | 1762 | |
| 1621 | | 1763 | |
| 1622 | pub async fn fork_confirm( | 1764 | pub async fn fork_confirm( |
| 1623 | State(state): State<AppState>, | 1765 | State(state): State<AppState>, |
| 2857 | } | 2999 | } |
| 2858 | div class="repo-actions" { | 3000 | div class="repo-actions" { |
| 2859 | @if ctx.viewer_id.is_some() { | 3001 | @if ctx.viewer_id.is_some() { |
| | 3002 | a class=(if ctx.bookmarked { "btn btn-active" } else { "btn" }) |
| | 3003 | href=(format!("/repo-bookmark/{}", ctx.repo.id)) { |
| | 3004 | (icon(Icon::Bookmark)) |
| | 3005 | span { (if ctx.bookmarked { "Bookmarked" } else { "Bookmark" }) } |
| | 3006 | } |
| 2860 | a class="btn" href=(format!("/repo-fork/{}", ctx.repo.id)) { | 3007 | a class="btn" href=(format!("/repo-fork/{}", ctx.repo.id)) { |
| 2861 | (icon(Icon::GitFork)) span { "Fork" } | 3008 | (icon(Icon::GitFork)) span { "Fork" } |
| 2862 | } | 3009 | } |