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 {
17281728 margin: 0;
17291729 text-align: center;
17301730 }
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+}
17311789 .profile-pronouns {
17321790 padding: 0.05rem 0.5rem;
17331791 border: 1px solid var(--fb-border);
crates/web/src/icons.rs +5 −0
@@ -42,6 +42,7 @@ pub enum Icon {
4242 Mirror,
4343 GitFork,
4444 Bookmark,
45+ Users,
4546 Github,
4647 Mastodon,
4748 }
@@ -131,6 +132,10 @@ impl Icon {
131132 }
132133 // Lucide "bookmark".
133134 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+ }
134139 Icon::Github => {
135140 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"/>"#
136141 }
crates/web/src/lib.rs +1 −0
@@ -320,6 +320,7 @@ pub fn build_router(state: AppState) -> Router {
320320 get(repo::fork_confirm).post(repo::fork_create),
321321 )
322322 .route("/repo-bookmark/{id}", get(repo::bookmark_toggle))
323+ .route("/user-follow/{username}", get(repo::follow_toggle))
323324 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.
324325 .route("/issue-new/{repo_id}", post(issues::create))
325326 .route("/issue/{id}/comment", post(issues::comment))
crates/web/src/repo.rs +131 −3
@@ -306,6 +306,7 @@ pub(crate) fn pagination_nav(
306306
307307 /// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups
308308 /// and repositories (filterable) on the right.
309+#[allow(clippy::too_many_lines)] // A flat per-tab dispatcher; splitting hurts readability.
309310 pub async fn user_page(
310311 State(state): State<AppState>,
311312 MaybeUser(viewer): MaybeUser,
@@ -320,6 +321,8 @@ pub async fn user_page(
320321 let tab = match lq.tab.as_deref() {
321322 Some("groups") => ProfileTab::Groups,
322323 Some("bookmarks") => ProfileTab::Bookmarks,
324+ Some("followers") => ProfileTab::Followers,
325+ Some("following") => ProfileTab::Following,
323326 _ => ProfileTab::Repos,
324327 };
325328 let q = lq.q.unwrap_or_default();
@@ -384,6 +387,27 @@ pub async fn user_page(
384387 let entries = resolve_bookmark_labels(&state, &visible, entries).await;
385388 html! { (repo_card("", "No bookmarks yet.", &entries)) }
386389 }
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,
387411 };
388412
389413 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
@@ -394,7 +418,7 @@ pub async fn user_page(
394418 };
395419 let body = html! {
396420 div class="profile-layout" {
397- aside class="profile-sidebar" { (profile_sidebar(&owner)) }
421+ aside class="profile-sidebar" { (profile_sidebar(&owner, followers, following, follow)) }
398422 div class="profile-main" {
399423 nav class="tabs profile-tabs" {
400424 (ptab(ProfileTab::Repos, "repos", "Repositories"))
@@ -408,12 +432,73 @@ pub async fn user_page(
408432 Ok((jar, page(&chrome, &owner.username, body)).into_response())
409433 }
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.
412437 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
413438 enum ProfileTab {
414439 Repos,
415440 Groups,
416441 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+ })
417502 }
418503
419504 /// Filter an arbitrary repo list to those `viewer` may read (for bookmarks).
@@ -1754,6 +1839,29 @@ pub async fn bookmark_toggle(
17541839 Ok(Redirect::to(&format!("/{owner}/{}", repo.path)).into_response())
17551840 }
17561841
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+
17571865 /// `GET /repo-fork/{id}` — confirm forking a readable repo to your account.
17581866 pub async fn fork_confirm(
17591867 State(state): State<AppState>,
@@ -2822,8 +2930,9 @@ fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
28222930
28232931 /// The profile sidebar card: avatar, name, handle, pronouns, markdown bio,
28242932 /// 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 {
28262934 let name = owner.display_name.as_deref().unwrap_or(&owner.username);
2935+ let user = &owner.username;
28272936 html! {
28282937 div class="card profile-card" {
28292938 img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt="";
@@ -2834,6 +2943,25 @@ fn profile_sidebar(owner: &User) -> Markup {
28342943 " · " (pronouns)
28352944 }
28362945 }
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+ }
28372965 @if let Some(bio) = non_empty(owner.bio.as_ref()) {
28382966 div class="profile-bio markdown" { (markdown::render(bio)) }
28392967 }