fabrica

hanna/fabrica

feat(web): followers — follow button, counts, and lists

9a8ec36 · hanna committed on 2026-07-26

The profile sidebar now shows "N followers · N following" (linking to
those lists) and a Follow/Unfollow button for signed-in visitors on other
people's profiles. Two new profile views, ?tab=followers and
?tab=following, render the users as follow-cards with per-row
Follow/Unfollow. A /user-follow/{username} link toggles the relationship.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +195 −3UnifiedSplit
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(
306306
307/// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups307/// `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.
309pub async fn user_page(310pub 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 };
388412
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}
410434
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)]
413enum ProfileTab {438enum 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).
448async 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}
418503
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}
17561841
1842/// `GET /user-follow/{username}` — toggle following a user, then return to their
1843/// profile. (A GET toggle, matching the bookmark link.)
1844pub 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.
1758pub async fn fork_confirm(1866pub 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 {
28222930
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.
2825fn profile_sidebar(owner: &User) -> Markup {2933fn 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 }