Wire profile follow controls
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
681f412572ad6d1d796c162a5b1662575b676d30- Parents
-
6b1f39a - Tree
82e1527
681f412
681f412572ad6d1d796c162a5b1662575b676d306b1f39a
82e1527internal/web/handlers/profile/follows.goadded@@ -0,0 +1,302 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package profile | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "errors" | |
| 8 | + "net/http" | |
| 9 | + "net/url" | |
| 10 | + "strconv" | |
| 11 | + "strings" | |
| 12 | + | |
| 13 | + "github.com/go-chi/chi/v5" | |
| 14 | + "github.com/jackc/pgx/v5" | |
| 15 | + "github.com/jackc/pgx/v5/pgtype" | |
| 16 | + | |
| 17 | + authpkg "github.com/tenseleyFlow/shithub/internal/auth" | |
| 18 | + "github.com/tenseleyFlow/shithub/internal/orgs" | |
| 19 | + orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" | |
| 20 | + "github.com/tenseleyFlow/shithub/internal/social" | |
| 21 | + socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc" | |
| 22 | + usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" | |
| 23 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" | |
| 24 | +) | |
| 25 | + | |
| 26 | +const followsPageSize = 50 | |
| 27 | + | |
| 28 | +type followState struct { | |
| 29 | + FollowersCount int64 | |
| 30 | + FollowingCount int64 | |
| 31 | + IsFollowing bool | |
| 32 | +} | |
| 33 | + | |
| 34 | +type followListItem struct { | |
| 35 | + Kind string | |
| 36 | + Username string | |
| 37 | + DisplayName string | |
| 38 | + AvatarURL string | |
| 39 | + URL string | |
| 40 | + FollowedAt string | |
| 41 | +} | |
| 42 | + | |
| 43 | +func (h *Handlers) socialDeps() social.Deps { | |
| 44 | + return social.Deps{ | |
| 45 | + Pool: h.d.Pool, | |
| 46 | + Limiter: h.d.Limiter, | |
| 47 | + Logger: h.d.Logger, | |
| 48 | + Audit: h.d.Audit, | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +func (h *Handlers) profileFollow(w http.ResponseWriter, r *http.Request) { | |
| 53 | + h.followAction(w, r, true) | |
| 54 | +} | |
| 55 | + | |
| 56 | +func (h *Handlers) profileUnfollow(w http.ResponseWriter, r *http.Request) { | |
| 57 | + h.followAction(w, r, false) | |
| 58 | +} | |
| 59 | + | |
| 60 | +func (h *Handlers) followAction(w http.ResponseWriter, r *http.Request, follow bool) { | |
| 61 | + viewer := middleware.CurrentUserFromContext(r.Context()) | |
| 62 | + if viewer.IsAnonymous() { | |
| 63 | + http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) | |
| 64 | + return | |
| 65 | + } | |
| 66 | + rawName := chi.URLParam(r, "username") | |
| 67 | + lower := strings.ToLower(rawName) | |
| 68 | + if authpkg.IsReserved(lower) { | |
| 69 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path) | |
| 70 | + return | |
| 71 | + } | |
| 72 | + if err := r.ParseForm(); err != nil { | |
| 73 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse") | |
| 74 | + return | |
| 75 | + } | |
| 76 | + | |
| 77 | + if p, err := orgs.Resolve(r.Context(), h.d.Pool, lower); err == nil { | |
| 78 | + switch p.Kind { | |
| 79 | + case orgs.PrincipalOrg: | |
| 80 | + h.followOrgAction(w, r, viewer, p.ID, follow) | |
| 81 | + return | |
| 82 | + case orgs.PrincipalUser: | |
| 83 | + h.followUserAction(w, r, viewer, rawName, follow) | |
| 84 | + return | |
| 85 | + } | |
| 86 | + } | |
| 87 | + h.followUserAction(w, r, viewer, rawName, follow) | |
| 88 | +} | |
| 89 | + | |
| 90 | +func (h *Handlers) followUserAction(w http.ResponseWriter, r *http.Request, viewer middleware.CurrentUser, rawName string, follow bool) { | |
| 91 | + target, err := h.q.GetUserByUsername(r.Context(), h.d.Pool, rawName) | |
| 92 | + if err != nil { | |
| 93 | + if errors.Is(err, pgx.ErrNoRows) { | |
| 94 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path) | |
| 95 | + return | |
| 96 | + } | |
| 97 | + h.d.Logger.ErrorContext(r.Context(), "profile follow: lookup user", "error", err) | |
| 98 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 99 | + return | |
| 100 | + } | |
| 101 | + if target.SuspendedAt.Valid || target.DeletedAt.Valid { | |
| 102 | + h.renderUnavailable(w, r, target.Username) | |
| 103 | + return | |
| 104 | + } | |
| 105 | + var actionErr error | |
| 106 | + if follow { | |
| 107 | + actionErr = social.FollowUser(r.Context(), h.socialDeps(), viewer.ID, target.ID) | |
| 108 | + } else { | |
| 109 | + actionErr = social.UnfollowUser(r.Context(), h.socialDeps(), viewer.ID, target.ID) | |
| 110 | + } | |
| 111 | + if actionErr != nil { | |
| 112 | + h.handleFollowError(w, r, actionErr) | |
| 113 | + return | |
| 114 | + } | |
| 115 | + redirectAfterProfileAction(w, r, "/"+target.Username) | |
| 116 | +} | |
| 117 | + | |
| 118 | +func (h *Handlers) followOrgAction(w http.ResponseWriter, r *http.Request, viewer middleware.CurrentUser, orgID int64, follow bool) { | |
| 119 | + org, err := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, orgID) | |
| 120 | + if err != nil || org.DeletedAt.Valid { | |
| 121 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path) | |
| 122 | + return | |
| 123 | + } | |
| 124 | + if org.SuspendedAt.Valid { | |
| 125 | + h.d.Render.HTTPError(w, r, http.StatusGone, string(org.Slug)) | |
| 126 | + return | |
| 127 | + } | |
| 128 | + var actionErr error | |
| 129 | + if follow { | |
| 130 | + actionErr = social.FollowOrg(r.Context(), h.socialDeps(), viewer.ID, org.ID) | |
| 131 | + } else { | |
| 132 | + actionErr = social.UnfollowOrg(r.Context(), h.socialDeps(), viewer.ID, org.ID) | |
| 133 | + } | |
| 134 | + if actionErr != nil { | |
| 135 | + h.handleFollowError(w, r, actionErr) | |
| 136 | + return | |
| 137 | + } | |
| 138 | + redirectAfterProfileAction(w, r, "/"+org.Slug) | |
| 139 | +} | |
| 140 | + | |
| 141 | +func (h *Handlers) handleFollowError(w http.ResponseWriter, r *http.Request, err error) { | |
| 142 | + switch { | |
| 143 | + case errors.Is(err, social.ErrNotLoggedIn): | |
| 144 | + http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) | |
| 145 | + case errors.Is(err, social.ErrCannotFollowSelf): | |
| 146 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "cannot follow yourself") | |
| 147 | + case errors.Is(err, social.ErrFollowRateLimit): | |
| 148 | + h.d.Render.HTTPError(w, r, http.StatusTooManyRequests, "rate limit") | |
| 149 | + default: | |
| 150 | + h.d.Logger.ErrorContext(r.Context(), "profile follow", "error", err) | |
| 151 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 152 | + } | |
| 153 | +} | |
| 154 | + | |
| 155 | +func redirectAfterProfileAction(w http.ResponseWriter, r *http.Request, fallback string) { | |
| 156 | + dest := fallback | |
| 157 | + if returnTo := strings.TrimSpace(r.PostFormValue("return_to")); safeProfileReturnTo(returnTo) { | |
| 158 | + dest = returnTo | |
| 159 | + } | |
| 160 | + http.Redirect(w, r, dest, http.StatusSeeOther) | |
| 161 | +} | |
| 162 | + | |
| 163 | +func safeProfileReturnTo(path string) bool { | |
| 164 | + if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") { | |
| 165 | + return false | |
| 166 | + } | |
| 167 | + u, err := url.Parse(path) | |
| 168 | + return err == nil && !u.IsAbs() && u.Host == "" && strings.HasPrefix(u.Path, "/") | |
| 169 | +} | |
| 170 | + | |
| 171 | +func (h *Handlers) userFollowState(ctx context.Context, userID int64, viewer middleware.CurrentUser) followState { | |
| 172 | + q := socialdb.New() | |
| 173 | + var out followState | |
| 174 | + out.FollowersCount, _ = q.CountFollowersForUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true}) | |
| 175 | + out.FollowingCount, _ = q.CountFollowingForUser(ctx, h.d.Pool, userID) | |
| 176 | + if !viewer.IsAnonymous() && viewer.ID != userID { | |
| 177 | + out.IsFollowing, _ = social.IsFollowingUser(ctx, h.socialDeps(), viewer.ID, userID) | |
| 178 | + } | |
| 179 | + return out | |
| 180 | +} | |
| 181 | + | |
| 182 | +func (h *Handlers) orgFollowState(ctx context.Context, orgID int64, viewer middleware.CurrentUser) followState { | |
| 183 | + q := socialdb.New() | |
| 184 | + var out followState | |
| 185 | + out.FollowersCount, _ = q.CountFollowersForOrg(ctx, h.d.Pool, pgtype.Int8{Int64: orgID, Valid: true}) | |
| 186 | + if !viewer.IsAnonymous() { | |
| 187 | + out.IsFollowing, _ = social.IsFollowingOrg(ctx, h.socialDeps(), viewer.ID, orgID) | |
| 188 | + } | |
| 189 | + return out | |
| 190 | +} | |
| 191 | + | |
| 192 | +func (h *Handlers) serveFollowersTab(w http.ResponseWriter, r *http.Request, user usersdb.User, viewer middleware.CurrentUser, isSelf bool) { | |
| 193 | + page := pageFromRequest(r) | |
| 194 | + rows, err := socialdb.New().ListFollowersForUser(r.Context(), h.d.Pool, socialdb.ListFollowersForUserParams{ | |
| 195 | + FolloweeUserID: pgtype.Int8{Int64: user.ID, Valid: true}, | |
| 196 | + Limit: followsPageSize, | |
| 197 | + Offset: int32((page - 1) * followsPageSize), | |
| 198 | + }) | |
| 199 | + if err != nil { | |
| 200 | + h.d.Logger.ErrorContext(r.Context(), "profile followers: list", "error", err) | |
| 201 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 202 | + return | |
| 203 | + } | |
| 204 | + state := h.userFollowState(r.Context(), user.ID, viewer) | |
| 205 | + items := make([]followListItem, 0, len(rows)) | |
| 206 | + for _, row := range rows { | |
| 207 | + items = append(items, userFollowListItem(row.Username, row.DisplayName, row.FollowedAt)) | |
| 208 | + } | |
| 209 | + h.renderFollowsTab(w, r, user, isSelf, "followers", state, items, page) | |
| 210 | +} | |
| 211 | + | |
| 212 | +func (h *Handlers) serveFollowingTab(w http.ResponseWriter, r *http.Request, user usersdb.User, viewer middleware.CurrentUser, isSelf bool) { | |
| 213 | + userRows, err := socialdb.New().ListFollowingUsersForUser(r.Context(), h.d.Pool, socialdb.ListFollowingUsersForUserParams{ | |
| 214 | + FollowerUserID: user.ID, | |
| 215 | + Limit: followsPageSize, | |
| 216 | + Offset: 0, | |
| 217 | + }) | |
| 218 | + if err != nil { | |
| 219 | + h.d.Logger.ErrorContext(r.Context(), "profile following users: list", "error", err) | |
| 220 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 221 | + return | |
| 222 | + } | |
| 223 | + orgRows, err := socialdb.New().ListFollowingOrgsForUser(r.Context(), h.d.Pool, socialdb.ListFollowingOrgsForUserParams{ | |
| 224 | + FollowerUserID: user.ID, | |
| 225 | + Limit: followsPageSize, | |
| 226 | + Offset: 0, | |
| 227 | + }) | |
| 228 | + if err != nil { | |
| 229 | + h.d.Logger.ErrorContext(r.Context(), "profile following orgs: list", "error", err) | |
| 230 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 231 | + return | |
| 232 | + } | |
| 233 | + state := h.userFollowState(r.Context(), user.ID, viewer) | |
| 234 | + items := make([]followListItem, 0, len(userRows)+len(orgRows)) | |
| 235 | + for _, row := range userRows { | |
| 236 | + items = append(items, userFollowListItem(row.Username, row.DisplayName, row.FollowedAt)) | |
| 237 | + } | |
| 238 | + for _, row := range orgRows { | |
| 239 | + items = append(items, orgFollowListItem(row.Slug, row.DisplayName, row.FollowedAt)) | |
| 240 | + } | |
| 241 | + h.renderFollowsTab(w, r, user, isSelf, "following", state, items, 1) | |
| 242 | +} | |
| 243 | + | |
| 244 | +func (h *Handlers) renderFollowsTab(w http.ResponseWriter, r *http.Request, user usersdb.User, isSelf bool, active string, state followState, items []followListItem, page int) { | |
| 245 | + displayName := user.DisplayName | |
| 246 | + if displayName == "" { | |
| 247 | + displayName = user.Username | |
| 248 | + } | |
| 249 | + data := map[string]any{ | |
| 250 | + "Title": followTabTitle(active) + " · " + user.Username, | |
| 251 | + "User": user, | |
| 252 | + "DisplayName": displayName, | |
| 253 | + "IsSelf": isSelf, | |
| 254 | + "AvatarURL": "/avatars/" + url.PathEscape(user.Username), | |
| 255 | + "Tabs": h.tabCounts(r.Context(), user.ID, middleware.CurrentUserFromContext(r.Context())), | |
| 256 | + "ActiveTab": active, | |
| 257 | + "FollowersCount": state.FollowersCount, | |
| 258 | + "FollowingCount": state.FollowingCount, | |
| 259 | + "Items": items, | |
| 260 | + "Page": page, | |
| 261 | + "HasPrev": page > 1, | |
| 262 | + "HasNext": len(items) == followsPageSize, | |
| 263 | + } | |
| 264 | + if err := h.d.Render.RenderPage(w, r, "profile/follows_tab", data); err != nil { | |
| 265 | + h.d.Logger.ErrorContext(r.Context(), "profile follows: render", "error", err) | |
| 266 | + } | |
| 267 | +} | |
| 268 | + | |
| 269 | +func followTabTitle(active string) string { | |
| 270 | + switch active { | |
| 271 | + case "followers": | |
| 272 | + return "Followers" | |
| 273 | + case "following": | |
| 274 | + return "Following" | |
| 275 | + default: | |
| 276 | + return "People" | |
| 277 | + } | |
| 278 | +} | |
| 279 | + | |
| 280 | +func pageFromRequest(r *http.Request) int { | |
| 281 | + v, _ := strconv.Atoi(r.URL.Query().Get("page")) | |
| 282 | + if v < 1 { | |
| 283 | + return 1 | |
| 284 | + } | |
| 285 | + return v | |
| 286 | +} | |
| 287 | + | |
| 288 | +func userFollowListItem(username, displayName string, followedAt pgtype.Timestamptz) followListItem { | |
| 289 | + return followListItem{ | |
| 290 | + Kind: "user", Username: username, DisplayName: displayName, | |
| 291 | + AvatarURL: "/avatars/" + url.PathEscape(username), URL: "/" + username, | |
| 292 | + FollowedAt: followedAt.Time.Format("Jan 2, 2006"), | |
| 293 | + } | |
| 294 | +} | |
| 295 | + | |
| 296 | +func orgFollowListItem(slug, displayName string, followedAt pgtype.Timestamptz) followListItem { | |
| 297 | + return followListItem{ | |
| 298 | + Kind: "org", Username: slug, DisplayName: displayName, | |
| 299 | + AvatarURL: "/avatars/" + url.PathEscape(slug), URL: "/" + slug, | |
| 300 | + FollowedAt: followedAt.Time.Format("Jan 2, 2006"), | |
| 301 | + } | |
| 302 | +} | |
internal/web/handlers/profile/org_profile.gomodified@@ -99,6 +99,7 @@ func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID | ||
| 99 | 99 | } |
| 100 | 100 | |
| 101 | 101 | repos := h.orgProfileRepos(ctx, org.ID, viewer) |
| 102 | + followState := h.orgFollowState(ctx, org.ID, viewer) | |
| 102 | 103 | repoRows := h.withOrgRepoActivity(ctx, string(org.Slug), limitOrgRepos(repos, orgHomepageRepoLimit)) |
| 103 | 104 | pinnedRepos, pinCandidates := h.orgPinData(ctx, org.ID, string(org.Slug), repos) |
| 104 | 105 | people := h.orgProfilePeople(ctx, q, org.ID) |
@@ -136,6 +137,11 @@ func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID | ||
| 136 | 137 | "RepoCount": int64(len(repos)), |
| 137 | 138 | "TeamCount": teamCount, |
| 138 | 139 | "MemberCount": memberCount, |
| 140 | + "FollowerCount": followState.FollowersCount, | |
| 141 | + "IsFollowing": followState.IsFollowing, | |
| 142 | + "FollowAction": "/" + url.PathEscape(org.Slug) + "/follow", | |
| 143 | + "UnfollowAction": "/" + url.PathEscape(org.Slug) + "/unfollow", | |
| 144 | + "ReturnTo": r.URL.RequestURI(), | |
| 139 | 145 | "People": limitOrgPeople(people, orgHomepagePeopleLimit), |
| 140 | 146 | "TopLanguages": orgTopLanguages(repos), |
| 141 | 147 | "TopTopics": orgTopTopics(repos), |
internal/web/handlers/profile/profile.gomodified@@ -26,6 +26,8 @@ import ( | ||
| 26 | 26 | "github.com/jackc/pgx/v5/pgxpool" |
| 27 | 27 | |
| 28 | 28 | authpkg "github.com/tenseleyFlow/shithub/internal/auth" |
| 29 | + "github.com/tenseleyFlow/shithub/internal/auth/audit" | |
| 30 | + "github.com/tenseleyFlow/shithub/internal/auth/throttle" | |
| 29 | 31 | "github.com/tenseleyFlow/shithub/internal/avatars" |
| 30 | 32 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 31 | 33 | "github.com/tenseleyFlow/shithub/internal/orgs" |
@@ -46,6 +48,8 @@ type Deps struct { | ||
| 46 | 48 | // ObjectStore is used to stream uploaded avatars. May be nil in tests |
| 47 | 49 | // or when S3 is not configured — falls back to identicon. |
| 48 | 50 | ObjectStore storage.ObjectStore |
| 51 | + Limiter *throttle.Limiter | |
| 52 | + Audit *audit.Recorder | |
| 49 | 53 | } |
| 50 | 54 | |
| 51 | 55 | // Handlers is the registered profile handler set. |
@@ -77,6 +81,8 @@ func (h *Handlers) MountAvatars(r chi.Router) { | ||
| 77 | 81 | func (h *Handlers) MountProfile(r chi.Router) { |
| 78 | 82 | r.Group(func(r chi.Router) { |
| 79 | 83 | r.Use(middleware.RequireUser) |
| 84 | + r.Post("/{username}/follow", h.profileFollow) | |
| 85 | + r.Post("/{username}/unfollow", h.profileUnfollow) | |
| 80 | 86 | r.Post("/{username}/pins", h.pinsUpdate) |
| 81 | 87 | }) |
| 82 | 88 | r.Get("/{username}", h.serveProfile) |
@@ -142,6 +148,12 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) { | ||
| 142 | 148 | // and ?tab=stars take their own renderers (each one is |
| 143 | 149 | // visibility-filtered independently). |
| 144 | 150 | switch r.URL.Query().Get("tab") { |
| 151 | + case "followers": | |
| 152 | + h.serveFollowersTab(w, r, user, viewer, isSelf) | |
| 153 | + return | |
| 154 | + case "following": | |
| 155 | + h.serveFollowingTab(w, r, user, viewer, isSelf) | |
| 156 | + return | |
| 145 | 157 | case "stars": |
| 146 | 158 | h.serveStarsTab(w, r, user, viewer, isSelf) |
| 147 | 159 | return |
@@ -159,6 +171,7 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) { | ||
| 159 | 171 | |
| 160 | 172 | avatarURL := fmt.Sprintf("/avatars/%s", url.PathEscape(user.Username)) |
| 161 | 173 | tabs := h.tabCounts(r.Context(), user.ID, viewer) |
| 174 | + followState := h.userFollowState(r.Context(), user.ID, viewer) | |
| 162 | 175 | visibleRepos := h.visibleUserRepos(r.Context(), user.ID, viewer) |
| 163 | 176 | pinnedRepos, pinCandidates := h.userPinData(r.Context(), user) |
| 164 | 177 | readme, hasReadme := h.profileReadme(r.Context(), user, viewer) |
@@ -179,6 +192,12 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) { | ||
| 179 | 192 | "WebsiteSafe": safeWebsite(user.Website), |
| 180 | 193 | "Tabs": tabs, |
| 181 | 194 | "ActiveTab": "overview", |
| 195 | + "FollowersCount": followState.FollowersCount, | |
| 196 | + "FollowingCount": followState.FollowingCount, | |
| 197 | + "IsFollowing": followState.IsFollowing, | |
| 198 | + "FollowAction": "/" + url.PathEscape(user.Username) + "/follow", | |
| 199 | + "UnfollowAction": "/" + url.PathEscape(user.Username) + "/unfollow", | |
| 200 | + "ReturnTo": r.URL.RequestURI(), | |
| 182 | 201 | "VisibleRepoCount": len(visibleRepos), |
| 183 | 202 | "Orgs": h.profileOrganizations(r.Context(), user.ID), |
| 184 | 203 | "ProfileReadme": readme, |
internal/web/handlers/profile/profile_test.gomodified@@ -61,9 +61,10 @@ func setupProfileEnvWithDeps(t *testing.T, objectStore storage.ObjectStore, repo | ||
| 61 | 61 | tmplFS := fstest.MapFS{ |
| 62 | 62 | "_layout.html": {Data: []byte(`{{ define "layout" }}<html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`)}, |
| 63 | 63 | "hello.html": {Data: []byte(`{{ define "page" }}home{{ end }}`)}, |
| 64 | - "profile/view.html": {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} PERIOD={{.Contributions.Period}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} YEARLINKS={{range .Contributions.Years}}{{.Year}}:{{.Active}}:{{.Href}};{{end}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)}, | |
| 64 | + "profile/view.html": {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }}{{ if .IsFollowing }} FOLLOWING=1{{ end }} FOLLOWERS={{.FollowersCount}} FOLLOWINGCOUNT={{.FollowingCount}} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} PERIOD={{.Contributions.Period}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} YEARLINKS={{range .Contributions.Years}}{{.Year}}:{{.Active}}:{{.Href}};{{end}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)}, | |
| 65 | + "profile/follows_tab.html": {Data: []byte(`{{ define "page" }}FOLLOWTAB={{.ActiveTab}} USER={{.User.Username}} TOTAL={{len .Items}} ITEMS={{range .Items}}{{.Kind}}:{{.Username}};{{end}}{{ end }}`)}, | |
| 65 | 66 | "profile/suspended.html": {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)}, |
| 66 | - "orgs/profile.html": {Data: []byte(`{{ define "page" }}ORG={{.Org.Slug}} REPOS={{len .Repos}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}} MEMBERS={{.MemberCount}} PEOPLE={{len .People}} NAMES={{range .Repos}}{{.Name}};{{end}} LANGS={{range .TopLanguages}}{{.Name}}={{.Count}};{{end}} TOPICS={{range .TopTopics}}{{.Name}}={{.Count}};{{end}} VIEWAS={{.ViewAs}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)}, | |
| 67 | + "orgs/profile.html": {Data: []byte(`{{ define "page" }}ORG={{.Org.Slug}}{{ if .IsFollowing }} FOLLOWING=1{{ end }} FOLLOWERS={{.FollowerCount}} REPOS={{len .Repos}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}} MEMBERS={{.MemberCount}} PEOPLE={{len .People}} NAMES={{range .Repos}}{{.Name}};{{end}} LANGS={{range .TopLanguages}}{{.Name}}={{.Count}};{{end}} TOPICS={{range .TopTopics}}{{.Name}}={{.Count}};{{end}} VIEWAS={{.ViewAs}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)}, | |
| 67 | 68 | "orgs/repositories.html": {Data: []byte(`{{ define "page" }}ORGREPOS={{.Org.Slug}} ACTIVE={{.ActiveOrgNav}} TOTAL={{.RepoCount}} FILTERED={{.FilteredCount}} PAGE={{.Page}}/{{.PageCount}} TYPE={{.SelectedType}} LANG={{.SelectedLanguage}} SORT={{.SelectedSort}} PREV={{.PrevHref}} NEXT={{.NextHref}} NAMES={{range .Repos}}{{.Name}};{{end}}{{range .PaginationPages}} P{{.Number}}={{.Current}}{{end}}{{ end }}`)}, |
| 68 | 69 | "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)}, |
| 69 | 70 | "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)}, |
@@ -315,6 +316,28 @@ func (e *profileEnv) postPins(t *testing.T, path string, user usersdb.User, repo | ||
| 315 | 316 | return resp |
| 316 | 317 | } |
| 317 | 318 | |
| 319 | +func (e *profileEnv) postFormAs(t *testing.T, path string, user usersdb.User, form url.Values) *http.Response { | |
| 320 | + t.Helper() | |
| 321 | + if form == nil { | |
| 322 | + form = url.Values{} | |
| 323 | + } | |
| 324 | + req, err := http.NewRequest(http.MethodPost, e.srv.URL+path, strings.NewReader(form.Encode())) | |
| 325 | + if err != nil { | |
| 326 | + t.Fatalf("request: %v", err) | |
| 327 | + } | |
| 328 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 329 | + if user.ID != 0 { | |
| 330 | + req.Header.Set("X-Test-User-ID", strconv.FormatInt(user.ID, 10)) | |
| 331 | + req.Header.Set("X-Test-Username", user.Username) | |
| 332 | + } | |
| 333 | + resp, err := newNonRedirClient(t).Do(req) | |
| 334 | + if err != nil { | |
| 335 | + t.Fatalf("POST: %v", err) | |
| 336 | + } | |
| 337 | + t.Cleanup(func() { _ = resp.Body.Close() }) | |
| 338 | + return resp | |
| 339 | +} | |
| 340 | + | |
| 318 | 341 | // =============================== tests ================================== |
| 319 | 342 | |
| 320 | 343 | func TestProfile_RendersForExistingUser(t *testing.T) { |
@@ -338,6 +361,93 @@ func TestProfile_RendersForExistingUser(t *testing.T) { | ||
| 338 | 361 | } |
| 339 | 362 | } |
| 340 | 363 | |
| 364 | +func TestProfile_FollowUserRoutesUpdateCountsAndState(t *testing.T) { | |
| 365 | + env := setupProfileEnv(t) | |
| 366 | + alice := env.insertUser(t, "alice", "Alice", "") | |
| 367 | + bob := env.insertUser(t, "bob", "Bob", "") | |
| 368 | + | |
| 369 | + resp := env.postFormAs(t, "/alice/follow", bob, url.Values{"return_to": []string{"/alice?tab=followers"}}) | |
| 370 | + if resp.StatusCode != http.StatusSeeOther { | |
| 371 | + t.Fatalf("follow status %d, want 303", resp.StatusCode) | |
| 372 | + } | |
| 373 | + if loc := resp.Header.Get("Location"); loc != "/alice?tab=followers" { | |
| 374 | + t.Fatalf("follow redirect = %q", loc) | |
| 375 | + } | |
| 376 | + var count int | |
| 377 | + if err := env.pool.QueryRow(context.Background(), | |
| 378 | + `SELECT count(*) FROM follows WHERE follower_user_id = $1 AND followee_user_id = $2`, | |
| 379 | + bob.ID, alice.ID, | |
| 380 | + ).Scan(&count); err != nil { | |
| 381 | + t.Fatalf("count follow: %v", err) | |
| 382 | + } | |
| 383 | + if count != 1 { | |
| 384 | + t.Fatalf("follow count = %d, want 1", count) | |
| 385 | + } | |
| 386 | + body := env.getAs(t, "/alice", bob) | |
| 387 | + for _, want := range []string{"FOLLOWING=1", "FOLLOWERS=1", "FOLLOWINGCOUNT=0"} { | |
| 388 | + if !strings.Contains(body, want) { | |
| 389 | + t.Fatalf("missing %q in body: %s", want, body) | |
| 390 | + } | |
| 391 | + } | |
| 392 | + followers := env.getAs(t, "/alice?tab=followers", alice) | |
| 393 | + if !strings.Contains(followers, "ITEMS=user:bob;") { | |
| 394 | + t.Fatalf("followers tab missing bob: %s", followers) | |
| 395 | + } | |
| 396 | + | |
| 397 | + resp = env.postFormAs(t, "/alice/unfollow", bob, nil) | |
| 398 | + if resp.StatusCode != http.StatusSeeOther { | |
| 399 | + t.Fatalf("unfollow status %d, want 303", resp.StatusCode) | |
| 400 | + } | |
| 401 | + if err := env.pool.QueryRow(context.Background(), | |
| 402 | + `SELECT count(*) FROM follows WHERE follower_user_id = $1 AND followee_user_id = $2`, | |
| 403 | + bob.ID, alice.ID, | |
| 404 | + ).Scan(&count); err != nil { | |
| 405 | + t.Fatalf("count unfollow: %v", err) | |
| 406 | + } | |
| 407 | + if count != 0 { | |
| 408 | + t.Fatalf("follow count after unfollow = %d, want 0", count) | |
| 409 | + } | |
| 410 | +} | |
| 411 | + | |
| 412 | +func TestProfile_FollowSelfRejected(t *testing.T) { | |
| 413 | + env := setupProfileEnv(t) | |
| 414 | + alice := env.insertUser(t, "alice", "Alice", "") | |
| 415 | + | |
| 416 | + resp := env.postFormAs(t, "/alice/follow", alice, nil) | |
| 417 | + if resp.StatusCode != http.StatusBadRequest { | |
| 418 | + t.Fatalf("follow self status %d, want 400", resp.StatusCode) | |
| 419 | + } | |
| 420 | +} | |
| 421 | + | |
| 422 | +func TestProfile_FollowOrgRoutesUpdateCountsAndState(t *testing.T) { | |
| 423 | + env := setupProfileEnv(t) | |
| 424 | + owner := env.insertUser(t, "owner", "Owner", "") | |
| 425 | + bob := env.insertUser(t, "bob", "Bob", "") | |
| 426 | + env.insertOrg(t, "acme", "Acme", "", owner) | |
| 427 | + | |
| 428 | + resp := env.postFormAs(t, "/acme/follow", bob, nil) | |
| 429 | + if resp.StatusCode != http.StatusSeeOther { | |
| 430 | + t.Fatalf("follow org status %d, want 303", resp.StatusCode) | |
| 431 | + } | |
| 432 | + var count int | |
| 433 | + if err := env.pool.QueryRow(context.Background(), | |
| 434 | + `SELECT count(*) FROM follows f JOIN orgs o ON o.id = f.followee_org_id | |
| 435 | + WHERE f.follower_user_id = $1 AND o.slug = 'acme'`, | |
| 436 | + bob.ID, | |
| 437 | + ).Scan(&count); err != nil { | |
| 438 | + t.Fatalf("count org follow: %v", err) | |
| 439 | + } | |
| 440 | + if count != 1 { | |
| 441 | + t.Fatalf("org follow count = %d, want 1", count) | |
| 442 | + } | |
| 443 | + body := env.getAs(t, "/acme", bob) | |
| 444 | + for _, want := range []string{"ORG=acme", "FOLLOWING=1", "FOLLOWERS=1"} { | |
| 445 | + if !strings.Contains(body, want) { | |
| 446 | + t.Fatalf("missing %q in org body: %s", want, body) | |
| 447 | + } | |
| 448 | + } | |
| 449 | +} | |
| 450 | + | |
| 341 | 451 | func TestProfile_OverviewDataUsesVisibleReposAndOrganizations(t *testing.T) { |
| 342 | 452 | t.Parallel() |
| 343 | 453 | env := setupProfileEnv(t) |
internal/web/profile_wiring.gomodified@@ -9,6 +9,8 @@ import ( | ||
| 9 | 9 | |
| 10 | 10 | "github.com/jackc/pgx/v5/pgxpool" |
| 11 | 11 | |
| 12 | + "github.com/tenseleyFlow/shithub/internal/auth/audit" | |
| 13 | + "github.com/tenseleyFlow/shithub/internal/auth/throttle" | |
| 12 | 14 | "github.com/tenseleyFlow/shithub/internal/infra/config" |
| 13 | 15 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 14 | 16 | profileh "github.com/tenseleyFlow/shithub/internal/web/handlers/profile" |
@@ -61,5 +63,7 @@ func buildProfileHandlers( | ||
| 61 | 63 | Pool: pool, |
| 62 | 64 | RepoFS: repoFS, |
| 63 | 65 | ObjectStore: objectStore, |
| 66 | + Limiter: throttle.NewLimiter(), | |
| 67 | + Audit: audit.NewRecorder(), | |
| 64 | 68 | }) |
| 65 | 69 | } |
internal/web/static/css/shithub.cssmodified@@ -1071,6 +1071,17 @@ code { | ||
| 1071 | 1071 | .shithub-profile-follow-counts strong { |
| 1072 | 1072 | color: var(--fg-default); |
| 1073 | 1073 | } |
| 1074 | +.shithub-profile-follow-counts a { | |
| 1075 | + color: var(--fg-muted); | |
| 1076 | + text-decoration: none; | |
| 1077 | +} | |
| 1078 | +.shithub-profile-follow-counts a:hover { | |
| 1079 | + color: var(--accent-fg, #4493f8); | |
| 1080 | + text-decoration: none; | |
| 1081 | +} | |
| 1082 | +.shithub-follow-form { | |
| 1083 | + margin: 0; | |
| 1084 | +} | |
| 1074 | 1085 | .shithub-profile-dot { |
| 1075 | 1086 | color: var(--fg-muted); |
| 1076 | 1087 | } |
@@ -1474,6 +1485,11 @@ code { | ||
| 1474 | 1485 | padding: 1.25rem 1rem 2rem; |
| 1475 | 1486 | gap: 1.5rem; |
| 1476 | 1487 | } |
| 1488 | + .shithub-profile-tab-container { | |
| 1489 | + grid-template-columns: 1fr; | |
| 1490 | + padding: 1.25rem 1rem 2rem; | |
| 1491 | + gap: 1.25rem; | |
| 1492 | + } | |
| 1477 | 1493 | .shithub-user-profile-sidebar { |
| 1478 | 1494 | display: grid; |
| 1479 | 1495 | grid-template-columns: 96px minmax(0, 1fr); |
@@ -2748,6 +2764,78 @@ code { | ||
| 2748 | 2764 | color: var(--fg-muted); |
| 2749 | 2765 | } |
| 2750 | 2766 | |
| 2767 | +.shithub-profile-tab-container { | |
| 2768 | + max-width: 1280px; | |
| 2769 | + margin: 0 auto; | |
| 2770 | + padding: 2rem; | |
| 2771 | + display: grid; | |
| 2772 | + grid-template-columns: 260px minmax(0, 1fr); | |
| 2773 | + gap: 2rem; | |
| 2774 | +} | |
| 2775 | +.shithub-profile-tab-sidebar { | |
| 2776 | + color: var(--fg-muted); | |
| 2777 | +} | |
| 2778 | +.shithub-profile-tab-avatar { | |
| 2779 | + width: 96px; | |
| 2780 | + height: 96px; | |
| 2781 | + border-radius: 50%; | |
| 2782 | + border: 1px solid var(--border-default); | |
| 2783 | + background: var(--canvas-subtle); | |
| 2784 | +} | |
| 2785 | +.shithub-profile-tab-sidebar h1 { | |
| 2786 | + margin: 0.75rem 0 0.15rem; | |
| 2787 | + font-size: 1.25rem; | |
| 2788 | + color: var(--fg-default); | |
| 2789 | +} | |
| 2790 | +.shithub-profile-tab-sidebar p { | |
| 2791 | + margin: 0; | |
| 2792 | +} | |
| 2793 | +.shithub-follow-list-head { | |
| 2794 | + border-bottom: 1px solid var(--border-default); | |
| 2795 | + padding-bottom: 0.75rem; | |
| 2796 | +} | |
| 2797 | +.shithub-follow-list-head h2 { | |
| 2798 | + margin: 0; | |
| 2799 | + font-size: 1.25rem; | |
| 2800 | +} | |
| 2801 | +.shithub-follow-list { | |
| 2802 | + list-style: none; | |
| 2803 | + margin: 0; | |
| 2804 | + padding: 0; | |
| 2805 | +} | |
| 2806 | +.shithub-follow-list-row { | |
| 2807 | + display: flex; | |
| 2808 | + gap: 0.9rem; | |
| 2809 | + padding: 1rem 0; | |
| 2810 | + border-bottom: 1px solid var(--border-default); | |
| 2811 | +} | |
| 2812 | +.shithub-follow-avatar img { | |
| 2813 | + width: 48px; | |
| 2814 | + height: 48px; | |
| 2815 | + border-radius: 50%; | |
| 2816 | + display: block; | |
| 2817 | + border: 1px solid var(--border-default); | |
| 2818 | +} | |
| 2819 | +.shithub-follow-list-body { | |
| 2820 | + min-width: 0; | |
| 2821 | + display: grid; | |
| 2822 | + gap: 0.15rem; | |
| 2823 | +} | |
| 2824 | +.shithub-follow-list-name { | |
| 2825 | + font-weight: 600; | |
| 2826 | + color: var(--fg-default); | |
| 2827 | +} | |
| 2828 | +.shithub-follow-list-handle { | |
| 2829 | + color: var(--fg-muted); | |
| 2830 | +} | |
| 2831 | +.shithub-follow-empty { | |
| 2832 | + margin-top: 1rem; | |
| 2833 | +} | |
| 2834 | +.shithub-follow-empty h3 { | |
| 2835 | + margin: 0; | |
| 2836 | + font-size: 1rem; | |
| 2837 | +} | |
| 2838 | + | |
| 2751 | 2839 | /* Repositories tab list. */ |
| 2752 | 2840 | .shithub-repo-list { list-style: none; padding: 0; margin: 0; } |
| 2753 | 2841 | .shithub-repo-list-row { |
internal/web/templates/orgs/profile.htmlmodified@@ -13,7 +13,17 @@ | ||
| 13 | 13 | </ul> |
| 14 | 14 | </div> |
| 15 | 15 | <div class="shithub-org-hero-actions"> |
| 16 | - {{ if .IsOwner }}<a href="/organizations/{{ .Org.Slug }}/settings/profile" class="shithub-button">Settings</a>{{ else }}<button type="button" class="shithub-button" disabled>Follow</button>{{ end }} | |
| 16 | + {{ if .IsOwner }} | |
| 17 | + <a href="/organizations/{{ .Org.Slug }}/settings/profile" class="shithub-button">Settings</a> | |
| 18 | + {{ else if .Viewer.ID }} | |
| 19 | + <form method="post" action="{{ if .IsFollowing }}{{ .UnfollowAction }}{{ else }}{{ .FollowAction }}{{ end }}" class="shithub-follow-form"> | |
| 20 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 21 | + <input type="hidden" name="return_to" value="{{ .ReturnTo }}"> | |
| 22 | + <button type="submit" class="shithub-button">{{ if .IsFollowing }}Unfollow{{ else }}Follow{{ end }}</button> | |
| 23 | + </form> | |
| 24 | + {{ else }} | |
| 25 | + <a href="/login?next={{ urlquery .ReturnTo }}" class="shithub-button">Follow</a> | |
| 26 | + {{ end }} | |
| 17 | 27 | </div> |
| 18 | 28 | </div> |
| 19 | 29 | </header> |
@@ -140,6 +150,7 @@ | ||
| 140 | 150 | <p class="shithub-muted">This organization has no public members.</p> |
| 141 | 151 | {{ end }} |
| 142 | 152 | <p><strong>{{ .MemberCount }}</strong> member{{ if ne .MemberCount 1 }}s{{ end }}</p> |
| 153 | + <p><strong>{{ .FollowerCount }}</strong> follower{{ if ne .FollowerCount 1 }}s{{ end }}</p> | |
| 143 | 154 | </section> |
| 144 | 155 | |
| 145 | 156 | <section class="shithub-org-sidebox"> |
internal/web/templates/profile/follows_tab.htmladded@@ -0,0 +1,51 @@ | ||
| 1 | +{{ define "page" -}} | |
| 2 | +<section class="shithub-profile-tab-page"> | |
| 3 | + <div class="shithub-profile-tabs-shell"> | |
| 4 | + {{ template "profile-tabs" . }} | |
| 5 | + </div> | |
| 6 | + | |
| 7 | + <div class="shithub-profile-tab-container"> | |
| 8 | + <aside class="shithub-profile-tab-sidebar" aria-label="{{ .User.Username }} profile summary"> | |
| 9 | + <img class="shithub-profile-tab-avatar" src="{{ .AvatarURL }}" alt="@{{ .User.Username }}" width="96" height="96"> | |
| 10 | + <h1>{{ .DisplayName }}</h1> | |
| 11 | + <p>@{{ .User.Username }}</p> | |
| 12 | + <p class="shithub-profile-follow-counts"> | |
| 13 | + {{ octicon "people" }} | |
| 14 | + <a href="/{{ .User.Username }}?tab=followers"{{ if eq .ActiveTab "followers" }} aria-current="page"{{ end }}><strong>{{ .FollowersCount }}</strong> followers</a> | |
| 15 | + <span class="shithub-profile-dot" aria-hidden="true">·</span> | |
| 16 | + <a href="/{{ .User.Username }}?tab=following"{{ if eq .ActiveTab "following" }} aria-current="page"{{ end }}><strong>{{ .FollowingCount }}</strong> following</a> | |
| 17 | + </p> | |
| 18 | + </aside> | |
| 19 | + | |
| 20 | + <main class="shithub-profile-tab-main"> | |
| 21 | + <header class="shithub-follow-list-head"> | |
| 22 | + <h2>{{ if eq .ActiveTab "followers" }}Followers{{ else }}Following{{ end }}</h2> | |
| 23 | + </header> | |
| 24 | + {{ if .Items }} | |
| 25 | + <ol class="shithub-follow-list"> | |
| 26 | + {{ range .Items }} | |
| 27 | + <li class="shithub-follow-list-row"> | |
| 28 | + <a href="{{ .URL }}" class="shithub-follow-avatar"><img src="{{ .AvatarURL }}" alt="" width="48" height="48"></a> | |
| 29 | + <div class="shithub-follow-list-body"> | |
| 30 | + <a href="{{ .URL }}" class="shithub-follow-list-name">{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</a> | |
| 31 | + <span class="shithub-follow-list-handle">{{ if eq .Kind "org" }}@{{ .Username }} · organization{{ else }}@{{ .Username }}{{ end }}</span> | |
| 32 | + <span class="shithub-muted">Followed {{ .FollowedAt }}</span> | |
| 33 | + </div> | |
| 34 | + </li> | |
| 35 | + {{ end }} | |
| 36 | + </ol> | |
| 37 | + {{ else }} | |
| 38 | + <div class="shithub-empty shithub-follow-empty"> | |
| 39 | + <h3>{{ if eq .ActiveTab "followers" }}No followers yet{{ else }}Not following anyone yet{{ end }}</h3> | |
| 40 | + </div> | |
| 41 | + {{ end }} | |
| 42 | + {{ if or .HasPrev .HasNext }} | |
| 43 | + <nav class="shithub-pagination" aria-label="Follows pagination"> | |
| 44 | + {{ if .HasPrev }}<a href="/{{ .User.Username }}?tab={{ .ActiveTab }}&page={{ sub .Page 1 }}">Previous</a>{{ else }}<span>Previous</span>{{ end }} | |
| 45 | + {{ if .HasNext }}<a href="/{{ .User.Username }}?tab={{ .ActiveTab }}&page={{ add .Page 1 }}">Next</a>{{ else }}<span>Next</span>{{ end }} | |
| 46 | + </nav> | |
| 47 | + {{ end }} | |
| 48 | + </main> | |
| 49 | + </div> | |
| 50 | +</section> | |
| 51 | +{{- end }} | |
internal/web/templates/profile/view.htmlmodified@@ -21,15 +21,21 @@ | ||
| 21 | 21 | |
| 22 | 22 | {{ if .IsSelf }} |
| 23 | 23 | <a href="/settings/profile" class="shithub-button shithub-button-block">Edit profile</a> |
| 24 | + {{ else if .Viewer.ID }} | |
| 25 | + <form method="post" action="{{ if .IsFollowing }}{{ .UnfollowAction }}{{ else }}{{ .FollowAction }}{{ end }}" class="shithub-follow-form"> | |
| 26 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 27 | + <input type="hidden" name="return_to" value="{{ .ReturnTo }}"> | |
| 28 | + <button type="submit" class="shithub-button shithub-button-block">{{ if .IsFollowing }}Unfollow{{ else }}Follow{{ end }}</button> | |
| 29 | + </form> | |
| 24 | 30 | {{ else }} |
| 25 | - <button type="button" class="shithub-button shithub-button-block" disabled>Follow</button> | |
| 31 | + <a href="/login?next={{ urlquery .ReturnTo }}" class="shithub-button shithub-button-block">Follow</a> | |
| 26 | 32 | {{ end }} |
| 27 | 33 | |
| 28 | 34 | <p class="shithub-profile-follow-counts"> |
| 29 | 35 | {{ octicon "people" }} |
| 30 | - <span><strong>0</strong> followers</span> | |
| 36 | + <a href="/{{ .User.Username }}?tab=followers"><strong>{{ .FollowersCount }}</strong> followers</a> | |
| 31 | 37 | <span class="shithub-profile-dot" aria-hidden="true">·</span> |
| 32 | - <span><strong>0</strong> following</span> | |
| 38 | + <a href="/{{ .User.Username }}?tab=following"><strong>{{ .FollowingCount }}</strong> following</a> | |
| 33 | 39 | </p> |
| 34 | 40 | |
| 35 | 41 | <ul class="shithub-profile-vcard"> |