Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +58 −0
| @@ -1728,6 +1728,64 @@ label.checkbox input { | |||
| 1728 | margin: 0; | 1728 | margin: 0; |
| 1729 | text-align: center; | 1729 | text-align: center; |
| 1730 | } | 1730 | } |
| 1731 | /* Follower/following counts and the follow button in the profile sidebar. */ | ||
| 1732 | .profile-follow { | ||
| 1733 | display: flex; | ||
| 1734 | align-items: center; | ||
| 1735 | justify-content: center; | ||
| 1736 | gap: 0.35rem; | ||
| 1737 | margin: 0.5rem 0 0; | ||
| 1738 | font-size: 0.9em; | ||
| 1739 | } | ||
| 1740 | .profile-follow a { | ||
| 1741 | color: inherit; | ||
| 1742 | } | ||
| 1743 | .profile-follow a:hover strong { | ||
| 1744 | color: var(--fb-accent); | ||
| 1745 | } | ||
| 1746 | .profile-follow-btn { | ||
| 1747 | display: block; | ||
| 1748 | text-align: center; | ||
| 1749 | margin: 0.75rem 0 0.25rem; | ||
| 1750 | } | ||
| 1751 | /* A list of users (followers/following) as follow-cards. */ | ||
| 1752 | .user-list { | ||
| 1753 | list-style: none; | ||
| 1754 | margin: 0; | ||
| 1755 | padding: 0; | ||
| 1756 | } | ||
| 1757 | .user-row { | ||
| 1758 | display: flex; | ||
| 1759 | align-items: flex-start; | ||
| 1760 | gap: 0.75rem; | ||
| 1761 | padding: 0.85rem 1rem; | ||
| 1762 | border-bottom: 1px solid var(--fb-border); | ||
| 1763 | } | ||
| 1764 | .user-row:last-child { | ||
| 1765 | border-bottom: none; | ||
| 1766 | } | ||
| 1767 | .user-row .avatar { | ||
| 1768 | width: 3rem; | ||
| 1769 | height: 3rem; | ||
| 1770 | flex: none; | ||
| 1771 | } | ||
| 1772 | .user-row-body { | ||
| 1773 | flex: 1; | ||
| 1774 | min-width: 0; | ||
| 1775 | } | ||
| 1776 | .user-row-name { | ||
| 1777 | font-weight: 600; | ||
| 1778 | color: var(--fb-fg); | ||
| 1779 | } | ||
| 1780 | .user-row-bio, | ||
| 1781 | .user-row-loc { | ||
| 1782 | margin: 0.2rem 0 0; | ||
| 1783 | font-size: 0.9em; | ||
| 1784 | } | ||
| 1785 | .user-row > .btn { | ||
| 1786 | flex: none; | ||
| 1787 | align-self: center; | ||
| 1788 | } | ||
| 1731 | .profile-pronouns { | 1789 | .profile-pronouns { |
| 1732 | padding: 0.05rem 0.5rem; | 1790 | padding: 0.05rem 0.5rem; |
| 1733 | border: 1px solid var(--fb-border); | 1791 | border: 1px solid var(--fb-border); |
crates/web/src/icons.rs +5 −0
| @@ -42,6 +42,7 @@ pub enum Icon { | |||
| 42 | Mirror, | 42 | Mirror, |
| 43 | GitFork, | 43 | GitFork, |
| 44 | Bookmark, | 44 | Bookmark, |
| 45 | Users, | ||
| 45 | Github, | 46 | Github, |
| 46 | Mastodon, | 47 | Mastodon, |
| 47 | } | 48 | } |
| @@ -131,6 +132,10 @@ impl Icon { | |||
| 131 | } | 132 | } |
| 132 | // Lucide "bookmark". | 133 | // Lucide "bookmark". |
| 133 | Icon::Bookmark => r#"<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>"#, | 134 | Icon::Bookmark => r#"<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>"#, |
| 135 | // Lucide "users": follower/following counts. | ||
| 136 | Icon::Users => { | ||
| 137 | r#"<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>"# | ||
| 138 | } | ||
| 134 | Icon::Github => { | 139 | Icon::Github => { |
| 135 | r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"# | 140 | r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"# |
| 136 | } | 141 | } |
crates/web/src/lib.rs +1 −0
| @@ -320,6 +320,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 320 | get(repo::fork_confirm).post(repo::fork_create), | 320 | get(repo::fork_confirm).post(repo::fork_create), |
| 321 | ) | 321 | ) |
| 322 | .route("/repo-bookmark/{id}", get(repo::bookmark_toggle)) | 322 | .route("/repo-bookmark/{id}", get(repo::bookmark_toggle)) |
| 323 | .route("/user-follow/{username}", get(repo::follow_toggle)) | ||
| 323 | // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all. | 324 | // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all. |
| 324 | .route("/issue-new/{repo_id}", post(issues::create)) | 325 | .route("/issue-new/{repo_id}", post(issues::create)) |
| 325 | .route("/issue/{id}/comment", post(issues::comment)) | 326 | .route("/issue/{id}/comment", post(issues::comment)) |
crates/web/src/repo.rs +131 −3
| @@ -306,6 +306,7 @@ pub(crate) fn pagination_nav( | |||
| 306 | 306 | ||
| 307 | /// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups | 307 | /// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups |
| 308 | /// and repositories (filterable) on the right. | 308 | /// and repositories (filterable) on the right. |
| 309 | #[allow(clippy::too_many_lines)] // A flat per-tab dispatcher; splitting hurts readability. | ||
| 309 | pub async fn user_page( | 310 | pub async fn user_page( |
| 310 | State(state): State<AppState>, | 311 | State(state): State<AppState>, |
| 311 | MaybeUser(viewer): MaybeUser, | 312 | MaybeUser(viewer): MaybeUser, |
| @@ -320,6 +321,8 @@ pub async fn user_page( | |||
| 320 | let tab = match lq.tab.as_deref() { | 321 | let tab = match lq.tab.as_deref() { |
| 321 | Some("groups") => ProfileTab::Groups, | 322 | Some("groups") => ProfileTab::Groups, |
| 322 | Some("bookmarks") => ProfileTab::Bookmarks, | 323 | Some("bookmarks") => ProfileTab::Bookmarks, |
| 324 | Some("followers") => ProfileTab::Followers, | ||
| 325 | Some("following") => ProfileTab::Following, | ||
| 323 | _ => ProfileTab::Repos, | 326 | _ => ProfileTab::Repos, |
| 324 | }; | 327 | }; |
| 325 | let q = lq.q.unwrap_or_default(); | 328 | let q = lq.q.unwrap_or_default(); |
| @@ -384,6 +387,27 @@ pub async fn user_page( | |||
| 384 | let entries = resolve_bookmark_labels(&state, &visible, entries).await; | 387 | let entries = resolve_bookmark_labels(&state, &visible, entries).await; |
| 385 | html! { (repo_card("", "No bookmarks yet.", &entries)) } | 388 | html! { (repo_card("", "No bookmarks yet.", &entries)) } |
| 386 | } | 389 | } |
| 390 | ProfileTab::Followers => { | ||
| 391 | let users = state.store.followers(&owner.id).await?; | ||
| 392 | user_list(&state, &users, viewer.as_ref(), "No followers yet.").await? | ||
| 393 | } | ||
| 394 | ProfileTab::Following => { | ||
| 395 | let users = state.store.following(&owner.id).await?; | ||
| 396 | user_list(&state, &users, viewer.as_ref(), "Not following anyone yet.").await? | ||
| 397 | } | ||
| 398 | }; | ||
| 399 | |||
| 400 | let followers = state.store.count_followers(&owner.id).await?; | ||
| 401 | let following = state.store.count_following(&owner.id).await?; | ||
| 402 | let follow = match &viewer { | ||
| 403 | Some(v) if v.id != owner.id => Some( | ||
| 404 | state | ||
| 405 | .store | ||
| 406 | .is_following(&v.id, &owner.id) | ||
| 407 | .await | ||
| 408 | .unwrap_or(false), | ||
| 409 | ), | ||
| 410 | _ => None, | ||
| 387 | }; | 411 | }; |
| 388 | 412 | ||
| 389 | let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string()); | 413 | let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string()); |
| @@ -394,7 +418,7 @@ pub async fn user_page( | |||
| 394 | }; | 418 | }; |
| 395 | let body = html! { | 419 | let body = html! { |
| 396 | div class="profile-layout" { | 420 | div class="profile-layout" { |
| 397 | aside class="profile-sidebar" { (profile_sidebar(&owner)) } | 421 | aside class="profile-sidebar" { (profile_sidebar(&owner, followers, following, follow)) } |
| 398 | div class="profile-main" { | 422 | div class="profile-main" { |
| 399 | nav class="tabs profile-tabs" { | 423 | nav class="tabs profile-tabs" { |
| 400 | (ptab(ProfileTab::Repos, "repos", "Repositories")) | 424 | (ptab(ProfileTab::Repos, "repos", "Repositories")) |
| @@ -408,12 +432,73 @@ pub async fn user_page( | |||
| 408 | Ok((jar, page(&chrome, &owner.username, body)).into_response()) | 432 | Ok((jar, page(&chrome, &owner.username, body)).into_response()) |
| 409 | } | 433 | } |
| 410 | 434 | ||
| 411 | /// The three tabs on a user's profile. | 435 | /// The tabs on a user's profile. Followers/Following are reached from the |
| 436 | /// sidebar counts rather than the main tab strip. | ||
| 412 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 437 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 413 | enum ProfileTab { | 438 | enum ProfileTab { |
| 414 | Repos, | 439 | Repos, |
| 415 | Groups, | 440 | Groups, |
| 416 | Bookmarks, | 441 | Bookmarks, |
| 442 | Followers, | ||
| 443 | Following, | ||
| 444 | } | ||
| 445 | |||
| 446 | /// Render a list of users as follow-cards (avatar, name, bio, location, and a | ||
| 447 | /// Follow/Unfollow button for signed-in viewers looking at other people). | ||
| 448 | async fn user_list( | ||
| 449 | state: &AppState, | ||
| 450 | users: &[User], | ||
| 451 | viewer: Option<&User>, | ||
| 452 | empty: &str, | ||
| 453 | ) -> AppResult<Markup> { | ||
| 454 | if users.is_empty() { | ||
| 455 | return Ok(html! { div class="card" { p class="muted" { (empty) } } }); | ||
| 456 | } | ||
| 457 | // Precompute the viewer's follow relationship to each listed user. | ||
| 458 | let mut following: std::collections::HashSet<String> = std::collections::HashSet::new(); | ||
| 459 | if let Some(v) = viewer { | ||
| 460 | for u in users { | ||
| 461 | if u.id != v.id | ||
| 462 | && state | ||
| 463 | .store | ||
| 464 | .is_following(&v.id, &u.id) | ||
| 465 | .await | ||
| 466 | .unwrap_or(false) | ||
| 467 | { | ||
| 468 | following.insert(u.id.clone()); | ||
| 469 | } | ||
| 470 | } | ||
| 471 | } | ||
| 472 | Ok(html! { | ||
| 473 | ul class="user-list card" { | ||
| 474 | @for u in users { | ||
| 475 | @let name = u.display_name.as_deref().unwrap_or(&u.username); | ||
| 476 | li class="user-row" { | ||
| 477 | img class="avatar" src={ "/avatar/" (u.username) } alt=""; | ||
| 478 | div class="user-row-body" { | ||
| 479 | div class="user-row-title" { | ||
| 480 | a class="user-row-name" href={ "/" (u.username) } { (name) } | ||
| 481 | " " span class="muted" { (u.username) } | ||
| 482 | } | ||
| 483 | @if let Some(bio) = non_empty(u.bio.as_ref()) { | ||
| 484 | p class="user-row-bio muted" { (bio) } | ||
| 485 | } | ||
| 486 | @if let Some(loc) = non_empty(u.location.as_ref()) { | ||
| 487 | p class="user-row-loc muted" { (icon(Icon::MapPin)) " " (loc) } | ||
| 488 | } | ||
| 489 | } | ||
| 490 | @if let Some(v) = viewer { | ||
| 491 | @if v.id != u.id { | ||
| 492 | a class=(if following.contains(&u.id) { "btn" } else { "btn btn-primary" }) | ||
| 493 | href={ "/user-follow/" (u.username) } { | ||
| 494 | (if following.contains(&u.id) { "Unfollow" } else { "Follow" }) | ||
| 495 | } | ||
| 496 | } | ||
| 497 | } | ||
| 498 | } | ||
| 499 | } | ||
| 500 | } | ||
| 501 | }) | ||
| 417 | } | 502 | } |
| 418 | 503 | ||
| 419 | /// Filter an arbitrary repo list to those `viewer` may read (for bookmarks). | 504 | /// Filter an arbitrary repo list to those `viewer` may read (for bookmarks). |
| @@ -1754,6 +1839,29 @@ pub async fn bookmark_toggle( | |||
| 1754 | Ok(Redirect::to(&format!("/{owner}/{}", repo.path)).into_response()) | 1839 | Ok(Redirect::to(&format!("/{owner}/{}", repo.path)).into_response()) |
| 1755 | } | 1840 | } |
| 1756 | 1841 | ||
| 1842 | /// `GET /user-follow/{username}` — toggle following a user, then return to their | ||
| 1843 | /// profile. (A GET toggle, matching the bookmark link.) | ||
| 1844 | pub async fn follow_toggle( | ||
| 1845 | State(state): State<AppState>, | ||
| 1846 | MaybeUser(viewer): MaybeUser, | ||
| 1847 | Path(username): Path<String>, | ||
| 1848 | ) -> AppResult<Response> { | ||
| 1849 | let Some(me) = viewer else { | ||
| 1850 | return Ok(Redirect::to("/login").into_response()); | ||
| 1851 | }; | ||
| 1852 | let Some(target) = state.store.user_by_username(&username).await? else { | ||
| 1853 | return Err(AppError::NotFound); | ||
| 1854 | }; | ||
| 1855 | if target.id != me.id { | ||
| 1856 | if state.store.is_following(&me.id, &target.id).await? { | ||
| 1857 | state.store.unfollow(&me.id, &target.id).await?; | ||
| 1858 | } else { | ||
| 1859 | state.store.follow(&me.id, &target.id).await?; | ||
| 1860 | } | ||
| 1861 | } | ||
| 1862 | Ok(Redirect::to(&format!("/{}", target.username)).into_response()) | ||
| 1863 | } | ||
| 1864 | |||
| 1757 | /// `GET /repo-fork/{id}` — confirm forking a readable repo to your account. | 1865 | /// `GET /repo-fork/{id}` — confirm forking a readable repo to your account. |
| 1758 | pub async fn fork_confirm( | 1866 | pub async fn fork_confirm( |
| 1759 | State(state): State<AppState>, | 1867 | State(state): State<AppState>, |
| @@ -2822,8 +2930,9 @@ fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup { | |||
| 2822 | 2930 | ||
| 2823 | /// The profile sidebar card: avatar, name, handle, pronouns, markdown bio, | 2931 | /// The profile sidebar card: avatar, name, handle, pronouns, markdown bio, |
| 2824 | /// location/social links, and the join date. | 2932 | /// location/social links, and the join date. |
| 2825 | fn profile_sidebar(owner: &User) -> Markup { | 2933 | fn profile_sidebar(owner: &User, followers: i64, following: i64, follow: Option<bool>) -> Markup { |
| 2826 | let name = owner.display_name.as_deref().unwrap_or(&owner.username); | 2934 | let name = owner.display_name.as_deref().unwrap_or(&owner.username); |
| 2935 | let user = &owner.username; | ||
| 2827 | html! { | 2936 | html! { |
| 2828 | div class="card profile-card" { | 2937 | div class="card profile-card" { |
| 2829 | img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt=""; | 2938 | img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt=""; |
| @@ -2834,6 +2943,25 @@ fn profile_sidebar(owner: &User) -> Markup { | |||
| 2834 | " · " (pronouns) | 2943 | " · " (pronouns) |
| 2835 | } | 2944 | } |
| 2836 | } | 2945 | } |
| 2946 | p class="profile-follow muted" { | ||
| 2947 | (icon(Icon::Users)) " " | ||
| 2948 | a href={ "/" (user) "?tab=followers" } { | ||
| 2949 | strong { (followers) } " follower" @if followers != 1 { "s" } | ||
| 2950 | } | ||
| 2951 | " · " | ||
| 2952 | a href={ "/" (user) "?tab=following" } { | ||
| 2953 | strong { (following) } " following" | ||
| 2954 | } | ||
| 2955 | } | ||
| 2956 | @match follow { | ||
| 2957 | Some(true) => { | ||
| 2958 | a class="btn profile-follow-btn" href={ "/user-follow/" (user) } { "Unfollow" } | ||
| 2959 | } | ||
| 2960 | Some(false) => { | ||
| 2961 | a class="btn btn-primary profile-follow-btn" href={ "/user-follow/" (user) } { "Follow" } | ||
| 2962 | } | ||
| 2963 | None => {} | ||
| 2964 | } | ||
| 2837 | @if let Some(bio) = non_empty(owner.bio.as_ref()) { | 2965 | @if let Some(bio) = non_empty(owner.bio.as_ref()) { |
| 2838 | div class="profile-bio markdown" { (markdown::render(bio)) } | 2966 | div class="profile-bio markdown" { (markdown::render(bio)) } |
| 2839 | } | 2967 | } |