Add org repositories page
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
392e06389c60cfcf49fd950956b8d3b1d9b7ddbd- Parents
-
9ecd8ed - Tree
3c426f5
392e063
392e06389c60cfcf49fd950956b8d3b1d9b7ddbd9ecd8ed
3c426f5docs/internal/orgs.mdmodified@@ -24,6 +24,7 @@ GET /organizations/new create form (auth required) | |||
| 24 | POST /organizations create submit | 24 | POST /organizations create submit |
| 25 | GET /{slug} /{user-or-org} — dispatched via principals.Resolve | 25 | GET /{slug} /{user-or-org} — dispatched via principals.Resolve |
| 26 | POST /{slug}/pins owner-only org profile pin customization | 26 | POST /{slug}/pins owner-only org profile pin customization |
| 27 | +GET /orgs/{org}/repositories repository list with filters + pagination | ||
| 27 | GET /{org}/people members + (owner-only) invite form | 28 | GET /{org}/people members + (owner-only) invite form |
| 28 | POST /{org}/people/invite invite by username OR email | 29 | POST /{org}/people/invite invite by username OR email |
| 29 | POST /{org}/people/{userID}/role change role (owner-only) | 30 | POST /{org}/people/{userID}/role change role (owner-only) |
@@ -99,9 +100,13 @@ site-admin, and impersonation write-mode fields. Anonymous viewers only | |||
| 99 | see public repositories; members and owners see whatever the policy | 100 | see public repositories; members and owners see whatever the policy |
| 100 | layer grants them. | 101 | layer grants them. |
| 101 | 102 | ||
| 102 | -There is no dedicated `/orgs/{org}/repositories` page yet. The Overview | 103 | +`GET /orgs/{org}/repositories` is the dedicated organization |
| 103 | -nav's Repositories item anchors to the homepage repository list until a | 104 | +repositories surface, matching GitHub's current org route shape. It |
| 104 | -full org repositories tab lands. | 105 | +uses the same policy-filtered visible repo set as the overview, then |
| 106 | +applies query, type, language, sort, and page parameters in the handler. | ||
| 107 | +The page renders 30 repositories per page with bordered GitHub-style | ||
| 108 | +rows, topics, language/license/star/fork metadata, default-branch | ||
| 109 | +activity sparklines, and numbered pagination. | ||
| 105 | 110 | ||
| 106 | `/{slug}` resolution flow inside `internal/web/handlers/profile/profile.go`: | 111 | `/{slug}` resolution flow inside `internal/web/handlers/profile/profile.go`: |
| 107 | 112 | ||
internal/web/handlers/handlers.gomodified@@ -131,6 +131,10 @@ type Deps struct { | |||
| 131 | // owner-gated inside the handler. Must register BEFORE the | 131 | // owner-gated inside the handler. Must register BEFORE the |
| 132 | // /{username} catch-all so the `people` segment matches. | 132 | // /{username} catch-all so the `people` segment matches. |
| 133 | OrgRoutesMounter func(chi.Router) | 133 | OrgRoutesMounter func(chi.Router) |
| 134 | + // OrgRepositoriesMounter registers /orgs/{org}/repositories. The | ||
| 135 | + // GitHub-style /orgs prefix avoids stealing /{user}/repositories | ||
| 136 | + // from a real user-owned repo named "repositories". | ||
| 137 | + OrgRepositoriesMounter func(chi.Router) | ||
| 134 | // OrgInvitationsMounter registers /invitations/{token} + | 138 | // OrgInvitationsMounter registers /invitations/{token} + |
| 135 | // accept/decline. RequireUser at the wiring layer. | 139 | // accept/decline. RequireUser at the wiring layer. |
| 136 | OrgInvitationsMounter func(chi.Router) | 140 | OrgInvitationsMounter func(chi.Router) |
@@ -319,6 +323,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http | |||
| 319 | if deps.OrgRoutesMounter != nil { | 323 | if deps.OrgRoutesMounter != nil { |
| 320 | deps.OrgRoutesMounter(r) | 324 | deps.OrgRoutesMounter(r) |
| 321 | } | 325 | } |
| 326 | + if deps.OrgRepositoriesMounter != nil { | ||
| 327 | + deps.OrgRepositoriesMounter(r) | ||
| 328 | + } | ||
| 322 | if deps.RepoHomeMounter != nil { | 329 | if deps.RepoHomeMounter != nil { |
| 323 | deps.RepoHomeMounter(r) | 330 | deps.RepoHomeMounter(r) |
| 324 | } | 331 | } |
internal/web/handlers/orgs/orgs.gomodified@@ -4,6 +4,7 @@ | |||
| 4 | // | 4 | // |
| 5 | // GET /organizations/new create form | 5 | // GET /organizations/new create form |
| 6 | // POST /organizations create submit | 6 | // POST /organizations create submit |
| 7 | +// GET /orgs/{org}/repositories repository list | ||
| 7 | // GET /{org}/people members + pending invites + invite form | 8 | // GET /{org}/people members + pending invites + invite form |
| 8 | // POST /{org}/people/invite invite by username or email | 9 | // POST /{org}/people/invite invite by username or email |
| 9 | // POST /{org}/people/{user}/role change role | 10 | // POST /{org}/people/{user}/role change role |
internal/web/handlers/profile/org_profile.gomodified@@ -115,30 +115,32 @@ func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID | |||
| 115 | 115 | ||
| 116 | avatarURL := "/avatars/" + url.PathEscape(org.Slug) | 116 | avatarURL := "/avatars/" + url.PathEscape(org.Slug) |
| 117 | data := map[string]any{ | 117 | data := map[string]any{ |
| 118 | - "Title": org.DisplayName, | 118 | + "Title": org.DisplayName, |
| 119 | - "OGTitle": org.DisplayName, | 119 | + "OGTitle": org.DisplayName, |
| 120 | - "OGDescription": org.Description, | 120 | + "OGDescription": org.Description, |
| 121 | - "OGImage": avatarURL, | 121 | + "OGImage": avatarURL, |
| 122 | - "Org": org, | 122 | + "Org": org, |
| 123 | - "AvatarURL": avatarURL, | 123 | + "AvatarURL": avatarURL, |
| 124 | - "ActiveOrgNav": "overview", | 124 | + "ActiveOrgNav": "overview", |
| 125 | - "WebsiteSafe": safeWebsite(org.Website), | 125 | + "WebsiteSafe": safeWebsite(org.Website), |
| 126 | - "Repos": repoRows, | 126 | + "Repos": repoRows, |
| 127 | - "PinnedRepos": pinnedRepos, | 127 | + "HasMoreRepos": len(repos) > orgHomepageRepoLimit, |
| 128 | - "PinCandidates": pinCandidates, | 128 | + "OrgRepositoriesURL": orgRepositoriesBaseURL(string(org.Slug)), |
| 129 | - "PinsRemaining": profilePinsRemaining(pinCandidates), | 129 | + "PinnedRepos": pinnedRepos, |
| 130 | - "RepoCount": int64(len(repos)), | 130 | + "PinCandidates": pinCandidates, |
| 131 | - "TeamCount": teamCount, | 131 | + "PinsRemaining": profilePinsRemaining(pinCandidates), |
| 132 | - "MemberCount": memberCount, | 132 | + "RepoCount": int64(len(repos)), |
| 133 | - "People": limitOrgPeople(people, orgHomepagePeopleLimit), | 133 | + "TeamCount": teamCount, |
| 134 | - "TopLanguages": orgTopLanguages(repos), | 134 | + "MemberCount": memberCount, |
| 135 | - "TopTopics": orgTopTopics(repos), | 135 | + "People": limitOrgPeople(people, orgHomepagePeopleLimit), |
| 136 | - "ViewAs": viewAs, | 136 | + "TopLanguages": orgTopLanguages(repos), |
| 137 | - "IsOwner": isOwner, | 137 | + "TopTopics": orgTopTopics(repos), |
| 138 | - "IsMember": isMember, | 138 | + "ViewAs": viewAs, |
| 139 | - "CanCustomizePins": isOwner, | 139 | + "IsOwner": isOwner, |
| 140 | - "PinsAction": "/" + url.PathEscape(org.Slug) + "/pins", | 140 | + "IsMember": isMember, |
| 141 | - "CanCreateRepo": isOwner || (isMember && org.AllowMemberRepoCreate), | 141 | + "CanCustomizePins": isOwner, |
| 142 | + "PinsAction": "/" + url.PathEscape(org.Slug) + "/pins", | ||
| 143 | + "CanCreateRepo": isOwner || (isMember && org.AllowMemberRepoCreate), | ||
| 142 | } | 144 | } |
| 143 | if !viewer.IsAnonymous() { | 145 | if !viewer.IsAnonymous() { |
| 144 | w.Header().Set("Cache-Control", "no-cache, private") | 146 | w.Header().Set("Cache-Control", "no-cache, private") |
internal/web/handlers/profile/org_repositories.goadded@@ -0,0 +1,417 @@ | |||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | + | ||
| 3 | +package profile | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "context" | ||
| 7 | + "net/http" | ||
| 8 | + "net/url" | ||
| 9 | + "sort" | ||
| 10 | + "strconv" | ||
| 11 | + "strings" | ||
| 12 | + | ||
| 13 | + "github.com/go-chi/chi/v5" | ||
| 14 | + | ||
| 15 | + "github.com/tenseleyFlow/shithub/internal/orgs" | ||
| 16 | + orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" | ||
| 17 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" | ||
| 18 | +) | ||
| 19 | + | ||
| 20 | +const orgRepositoriesPerPage = 30 | ||
| 21 | + | ||
| 22 | +type orgRepositoryFilterOption struct { | ||
| 23 | + Label string | ||
| 24 | + Value string | ||
| 25 | + Href string | ||
| 26 | + Selected bool | ||
| 27 | + Count int | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +type orgRepositoryPageLink struct { | ||
| 31 | + Number int | ||
| 32 | + Href string | ||
| 33 | + Current bool | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// MountOrgRepositories registers the GitHub-style organization | ||
| 37 | +// repositories route. It deliberately uses /orgs/{org}/repositories | ||
| 38 | +// instead of /{org}/repositories so ordinary repos named "repositories" | ||
| 39 | +// remain reachable at /{owner}/repositories. | ||
| 40 | +func (h *Handlers) MountOrgRepositories(r chi.Router) { | ||
| 41 | + r.Get("/orgs/{org}/repositories", h.serveOrgRepositories) | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +func (h *Handlers) serveOrgRepositories(w http.ResponseWriter, r *http.Request) { | ||
| 45 | + ctx := r.Context() | ||
| 46 | + org, err := orgsdb.New().GetOrgBySlug(ctx, h.d.Pool, chi.URLParam(r, "org")) | ||
| 47 | + if err != nil { | ||
| 48 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path) | ||
| 49 | + return | ||
| 50 | + } | ||
| 51 | + if org.DeletedAt.Valid { | ||
| 52 | + h.renderUnavailable(w, r, string(org.Slug)) | ||
| 53 | + return | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + viewer := middleware.CurrentUserFromContext(ctx) | ||
| 57 | + isOwner, isMember, viewAs := h.orgViewerState(ctx, org.ID, viewer) | ||
| 58 | + repos := h.orgProfileRepos(ctx, org.ID, viewer) | ||
| 59 | + | ||
| 60 | + query := strings.TrimSpace(r.URL.Query().Get("q")) | ||
| 61 | + typeFilter := normalizeOrgRepositoryType(r.URL.Query().Get("type")) | ||
| 62 | + languageFilter := strings.TrimSpace(r.URL.Query().Get("language")) | ||
| 63 | + sortKey := normalizeOrgRepositorySort(r.URL.Query().Get("sort")) | ||
| 64 | + | ||
| 65 | + filtered := filterOrgRepositories(repos, query, typeFilter, languageFilter) | ||
| 66 | + sortOrgRepositories(filtered, sortKey) | ||
| 67 | + page := parseOrgRepositoryPage(r.URL.Query().Get("page")) | ||
| 68 | + pageRepos, currentPage, pageCount := paginateOrgRepositories(filtered, page) | ||
| 69 | + pageRepos = h.withOrgRepoActivity(ctx, string(org.Slug), pageRepos) | ||
| 70 | + | ||
| 71 | + people := h.orgProfilePeople(ctx, orgsdb.New(), org.ID) | ||
| 72 | + teamCount := int64(0) | ||
| 73 | + if isMember || viewer.IsSiteAdmin { | ||
| 74 | + _ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM teams WHERE org_id = $1`, org.ID).Scan(&teamCount) | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + baseURL := orgRepositoriesBaseURL(string(org.Slug)) | ||
| 78 | + typeFilters := orgRepositoryTypeFilters(baseURL, repos, query, typeFilter, languageFilter, sortKey) | ||
| 79 | + languageFilters := orgRepositoryLanguageFilters(baseURL, repos, query, typeFilter, languageFilter, sortKey) | ||
| 80 | + sortOptions := orgRepositorySortOptions(baseURL, query, typeFilter, languageFilter, sortKey) | ||
| 81 | + avatarURL := "/avatars/" + url.PathEscape(string(org.Slug)) | ||
| 82 | + titleName := org.DisplayName | ||
| 83 | + if titleName == "" { | ||
| 84 | + titleName = string(org.Slug) | ||
| 85 | + } | ||
| 86 | + data := map[string]any{ | ||
| 87 | + "Title": titleName + " · repositories", | ||
| 88 | + "OGTitle": titleName + " repositories", | ||
| 89 | + "OGDescription": org.Description, | ||
| 90 | + "OGImage": avatarURL, | ||
| 91 | + "Org": org, | ||
| 92 | + "AvatarURL": avatarURL, | ||
| 93 | + "ActiveOrgNav": "repositories", | ||
| 94 | + "Repos": pageRepos, | ||
| 95 | + "RepoCount": int64(len(repos)), | ||
| 96 | + "FilteredCount": len(filtered), | ||
| 97 | + "HasActiveFilters": query != "" || typeFilter != "all" || languageFilter != "" || sortKey != "updated", | ||
| 98 | + "Query": query, | ||
| 99 | + "SelectedType": typeFilter, | ||
| 100 | + "SelectedTypeLabel": selectedOrgRepositoryOptionLabel(typeFilters, "All"), | ||
| 101 | + "SelectedLanguage": languageFilter, | ||
| 102 | + "SelectedLanguageLabel": selectedOrgRepositoryOptionLabel(languageFilters, "All languages"), | ||
| 103 | + "SelectedSort": sortKey, | ||
| 104 | + "SelectedSortLabel": selectedOrgRepositoryOptionLabel(sortOptions, "Last updated"), | ||
| 105 | + "TypeFilters": typeFilters, | ||
| 106 | + "LanguageFilters": languageFilters, | ||
| 107 | + "SortOptions": sortOptions, | ||
| 108 | + "Page": currentPage, | ||
| 109 | + "PageCount": pageCount, | ||
| 110 | + "PaginationPages": orgRepositoryPaginationPages(baseURL, query, typeFilter, languageFilter, sortKey, currentPage, pageCount), | ||
| 111 | + "HasPrev": currentPage > 1 && pageCount > 0, | ||
| 112 | + "HasNext": currentPage < pageCount, | ||
| 113 | + "PrevHref": orgRepositoryURL(baseURL, query, typeFilter, languageFilter, sortKey, currentPage-1), | ||
| 114 | + "NextHref": orgRepositoryURL(baseURL, query, typeFilter, languageFilter, sortKey, currentPage+1), | ||
| 115 | + "MemberCount": int64(len(people)), | ||
| 116 | + "TeamCount": teamCount, | ||
| 117 | + "ViewAs": viewAs, | ||
| 118 | + "IsOwner": isOwner, | ||
| 119 | + "IsMember": isMember, | ||
| 120 | + "CanCreateRepo": isOwner || (isMember && org.AllowMemberRepoCreate), | ||
| 121 | + } | ||
| 122 | + if !viewer.IsAnonymous() { | ||
| 123 | + w.Header().Set("Cache-Control", "no-cache, private") | ||
| 124 | + } else { | ||
| 125 | + w.Header().Set("Cache-Control", "max-age=120") | ||
| 126 | + } | ||
| 127 | + if err := h.d.Render.RenderPage(w, r, "orgs/repositories", data); err != nil { | ||
| 128 | + h.d.Logger.ErrorContext(ctx, "orgs repositories: render", "error", err) | ||
| 129 | + } | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +func (h *Handlers) orgViewerState(ctx context.Context, orgID int64, viewer middleware.CurrentUser) (bool, bool, string) { | ||
| 133 | + isOwner := false | ||
| 134 | + isMember := false | ||
| 135 | + if !viewer.IsAnonymous() { | ||
| 136 | + deps := orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger} | ||
| 137 | + isOwner, _ = orgs.IsOwner(ctx, deps, orgID, viewer.ID) | ||
| 138 | + isMember, _ = orgs.IsMember(ctx, deps, orgID, viewer.ID) | ||
| 139 | + } | ||
| 140 | + viewAs := "Public" | ||
| 141 | + switch { | ||
| 142 | + case !viewer.IsAnonymous() && viewer.IsSiteAdmin: | ||
| 143 | + viewAs = "Site admin" | ||
| 144 | + case isOwner: | ||
| 145 | + viewAs = "Owner" | ||
| 146 | + case isMember: | ||
| 147 | + viewAs = "Member" | ||
| 148 | + } | ||
| 149 | + return isOwner, isMember, viewAs | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +func filterOrgRepositories(repos []orgProfileRepo, query, typeFilter, languageFilter string) []orgProfileRepo { | ||
| 153 | + query = strings.ToLower(strings.TrimSpace(query)) | ||
| 154 | + languageFilter = strings.TrimSpace(languageFilter) | ||
| 155 | + out := make([]orgProfileRepo, 0, len(repos)) | ||
| 156 | + for _, repo := range repos { | ||
| 157 | + if !orgRepositoryMatchesType(repo, typeFilter) { | ||
| 158 | + continue | ||
| 159 | + } | ||
| 160 | + if languageFilter != "" && !strings.EqualFold(repo.PrimaryLanguage, languageFilter) { | ||
| 161 | + continue | ||
| 162 | + } | ||
| 163 | + if query != "" && !orgRepositoryMatchesQuery(repo, query) { | ||
| 164 | + continue | ||
| 165 | + } | ||
| 166 | + out = append(out, repo) | ||
| 167 | + } | ||
| 168 | + return out | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +func orgRepositoryMatchesType(repo orgProfileRepo, typeFilter string) bool { | ||
| 172 | + switch typeFilter { | ||
| 173 | + case "public": | ||
| 174 | + return repo.Visibility == "public" | ||
| 175 | + case "private": | ||
| 176 | + return repo.Visibility == "private" | ||
| 177 | + case "source": | ||
| 178 | + return !repo.IsFork && !repo.IsArchived | ||
| 179 | + case "fork": | ||
| 180 | + return repo.IsFork | ||
| 181 | + case "archived": | ||
| 182 | + return repo.IsArchived | ||
| 183 | + default: | ||
| 184 | + return true | ||
| 185 | + } | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +func orgRepositoryMatchesQuery(repo orgProfileRepo, query string) bool { | ||
| 189 | + if strings.Contains(strings.ToLower(repo.Name), query) || | ||
| 190 | + strings.Contains(strings.ToLower(repo.Description), query) || | ||
| 191 | + strings.Contains(strings.ToLower(repo.PrimaryLanguage), query) { | ||
| 192 | + return true | ||
| 193 | + } | ||
| 194 | + for _, topic := range repo.Topics { | ||
| 195 | + if strings.Contains(strings.ToLower(topic), query) { | ||
| 196 | + return true | ||
| 197 | + } | ||
| 198 | + } | ||
| 199 | + return false | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +func sortOrgRepositories(repos []orgProfileRepo, sortKey string) { | ||
| 203 | + sort.SliceStable(repos, func(i, j int) bool { | ||
| 204 | + switch sortKey { | ||
| 205 | + case "name": | ||
| 206 | + return strings.ToLower(repos[i].Name) < strings.ToLower(repos[j].Name) | ||
| 207 | + case "stars": | ||
| 208 | + if repos[i].StarCount != repos[j].StarCount { | ||
| 209 | + return repos[i].StarCount > repos[j].StarCount | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + if !repos[i].UpdatedAt.Equal(repos[j].UpdatedAt) { | ||
| 213 | + return repos[i].UpdatedAt.After(repos[j].UpdatedAt) | ||
| 214 | + } | ||
| 215 | + return strings.ToLower(repos[i].Name) < strings.ToLower(repos[j].Name) | ||
| 216 | + }) | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +func normalizeOrgRepositoryType(value string) string { | ||
| 220 | + switch strings.ToLower(strings.TrimSpace(value)) { | ||
| 221 | + case "public", "private", "fork", "archived": | ||
| 222 | + return strings.ToLower(strings.TrimSpace(value)) | ||
| 223 | + case "source", "sources": | ||
| 224 | + return "source" | ||
| 225 | + case "forks": | ||
| 226 | + return "fork" | ||
| 227 | + default: | ||
| 228 | + return "all" | ||
| 229 | + } | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +func normalizeOrgRepositorySort(value string) string { | ||
| 233 | + switch strings.ToLower(strings.TrimSpace(value)) { | ||
| 234 | + case "name", "stars": | ||
| 235 | + return strings.ToLower(strings.TrimSpace(value)) | ||
| 236 | + default: | ||
| 237 | + return "updated" | ||
| 238 | + } | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +func parseOrgRepositoryPage(value string) int { | ||
| 242 | + page, err := strconv.Atoi(strings.TrimSpace(value)) | ||
| 243 | + if err != nil || page < 1 { | ||
| 244 | + return 1 | ||
| 245 | + } | ||
| 246 | + return page | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +func paginateOrgRepositories(repos []orgProfileRepo, page int) ([]orgProfileRepo, int, int) { | ||
| 250 | + if len(repos) == 0 { | ||
| 251 | + return nil, 1, 0 | ||
| 252 | + } | ||
| 253 | + pageCount := (len(repos) + orgRepositoriesPerPage - 1) / orgRepositoriesPerPage | ||
| 254 | + if page > pageCount { | ||
| 255 | + page = pageCount | ||
| 256 | + } | ||
| 257 | + start := (page - 1) * orgRepositoriesPerPage | ||
| 258 | + end := start + orgRepositoriesPerPage | ||
| 259 | + if end > len(repos) { | ||
| 260 | + end = len(repos) | ||
| 261 | + } | ||
| 262 | + return repos[start:end], page, pageCount | ||
| 263 | +} | ||
| 264 | + | ||
| 265 | +func orgRepositoriesBaseURL(orgSlug string) string { | ||
| 266 | + return "/orgs/" + url.PathEscape(orgSlug) + "/repositories" | ||
| 267 | +} | ||
| 268 | + | ||
| 269 | +func orgRepositoryURL(baseURL, query, typeFilter, languageFilter, sortKey string, page int) string { | ||
| 270 | + values := url.Values{} | ||
| 271 | + if query != "" { | ||
| 272 | + values.Set("q", query) | ||
| 273 | + } | ||
| 274 | + if typeFilter != "" && typeFilter != "all" { | ||
| 275 | + values.Set("type", typeFilter) | ||
| 276 | + } | ||
| 277 | + if languageFilter != "" { | ||
| 278 | + values.Set("language", languageFilter) | ||
| 279 | + } | ||
| 280 | + if sortKey != "" && sortKey != "updated" { | ||
| 281 | + values.Set("sort", sortKey) | ||
| 282 | + } | ||
| 283 | + if page > 1 { | ||
| 284 | + values.Set("page", strconv.Itoa(page)) | ||
| 285 | + } | ||
| 286 | + if encoded := values.Encode(); encoded != "" { | ||
| 287 | + return baseURL + "?" + encoded | ||
| 288 | + } | ||
| 289 | + return baseURL | ||
| 290 | +} | ||
| 291 | + | ||
| 292 | +func orgRepositoryTypeFilters(baseURL string, repos []orgProfileRepo, query, selected, language, sortKey string) []orgRepositoryFilterOption { | ||
| 293 | + counts := map[string]int{"all": len(repos)} | ||
| 294 | + for _, repo := range repos { | ||
| 295 | + if repo.Visibility == "public" { | ||
| 296 | + counts["public"]++ | ||
| 297 | + } | ||
| 298 | + if repo.Visibility == "private" { | ||
| 299 | + counts["private"]++ | ||
| 300 | + } | ||
| 301 | + if !repo.IsFork && !repo.IsArchived { | ||
| 302 | + counts["source"]++ | ||
| 303 | + } | ||
| 304 | + if repo.IsFork { | ||
| 305 | + counts["fork"]++ | ||
| 306 | + } | ||
| 307 | + if repo.IsArchived { | ||
| 308 | + counts["archived"]++ | ||
| 309 | + } | ||
| 310 | + } | ||
| 311 | + specs := []struct { | ||
| 312 | + value string | ||
| 313 | + label string | ||
| 314 | + }{ | ||
| 315 | + {value: "all", label: "All"}, | ||
| 316 | + {value: "public", label: "Public"}, | ||
| 317 | + {value: "private", label: "Private"}, | ||
| 318 | + {value: "source", label: "Sources"}, | ||
| 319 | + {value: "fork", label: "Forks"}, | ||
| 320 | + {value: "archived", label: "Archived"}, | ||
| 321 | + } | ||
| 322 | + out := make([]orgRepositoryFilterOption, 0, len(specs)) | ||
| 323 | + for _, spec := range specs { | ||
| 324 | + if spec.value == "private" && counts[spec.value] == 0 && selected != spec.value { | ||
| 325 | + continue | ||
| 326 | + } | ||
| 327 | + out = append(out, orgRepositoryFilterOption{ | ||
| 328 | + Label: spec.label, | ||
| 329 | + Value: spec.value, | ||
| 330 | + Href: orgRepositoryURL(baseURL, query, spec.value, language, sortKey, 1), | ||
| 331 | + Selected: selected == spec.value, | ||
| 332 | + Count: counts[spec.value], | ||
| 333 | + }) | ||
| 334 | + } | ||
| 335 | + return out | ||
| 336 | +} | ||
| 337 | + | ||
| 338 | +func orgRepositoryLanguageFilters(baseURL string, repos []orgProfileRepo, query, typeFilter, selected, sortKey string) []orgRepositoryFilterOption { | ||
| 339 | + counts := map[string]int{} | ||
| 340 | + for _, repo := range repos { | ||
| 341 | + if repo.PrimaryLanguage != "" { | ||
| 342 | + counts[repo.PrimaryLanguage]++ | ||
| 343 | + } | ||
| 344 | + } | ||
| 345 | + languages := make([]string, 0, len(counts)) | ||
| 346 | + for language := range counts { | ||
| 347 | + languages = append(languages, language) | ||
| 348 | + } | ||
| 349 | + sort.SliceStable(languages, func(i, j int) bool { | ||
| 350 | + if counts[languages[i]] != counts[languages[j]] { | ||
| 351 | + return counts[languages[i]] > counts[languages[j]] | ||
| 352 | + } | ||
| 353 | + return languages[i] < languages[j] | ||
| 354 | + }) | ||
| 355 | + out := []orgRepositoryFilterOption{{ | ||
| 356 | + Label: "All languages", | ||
| 357 | + Value: "", | ||
| 358 | + Href: orgRepositoryURL(baseURL, query, typeFilter, "", sortKey, 1), | ||
| 359 | + Selected: selected == "", | ||
| 360 | + Count: len(repos), | ||
| 361 | + }} | ||
| 362 | + for _, language := range languages { | ||
| 363 | + out = append(out, orgRepositoryFilterOption{ | ||
| 364 | + Label: language, | ||
| 365 | + Value: language, | ||
| 366 | + Href: orgRepositoryURL(baseURL, query, typeFilter, language, sortKey, 1), | ||
| 367 | + Selected: strings.EqualFold(selected, language), | ||
| 368 | + Count: counts[language], | ||
| 369 | + }) | ||
| 370 | + } | ||
| 371 | + return out | ||
| 372 | +} | ||
| 373 | + | ||
| 374 | +func orgRepositorySortOptions(baseURL, query, typeFilter, language, selected string) []orgRepositoryFilterOption { | ||
| 375 | + specs := []struct { | ||
| 376 | + value string | ||
| 377 | + label string | ||
| 378 | + }{ | ||
| 379 | + {value: "updated", label: "Last updated"}, | ||
| 380 | + {value: "name", label: "Name"}, | ||
| 381 | + {value: "stars", label: "Stars"}, | ||
| 382 | + } | ||
| 383 | + out := make([]orgRepositoryFilterOption, 0, len(specs)) | ||
| 384 | + for _, spec := range specs { | ||
| 385 | + out = append(out, orgRepositoryFilterOption{ | ||
| 386 | + Label: spec.label, | ||
| 387 | + Value: spec.value, | ||
| 388 | + Href: orgRepositoryURL(baseURL, query, typeFilter, language, spec.value, 1), | ||
| 389 | + Selected: selected == spec.value, | ||
| 390 | + }) | ||
| 391 | + } | ||
| 392 | + return out | ||
| 393 | +} | ||
| 394 | + | ||
| 395 | +func selectedOrgRepositoryOptionLabel(options []orgRepositoryFilterOption, fallback string) string { | ||
| 396 | + for _, option := range options { | ||
| 397 | + if option.Selected { | ||
| 398 | + return option.Label | ||
| 399 | + } | ||
| 400 | + } | ||
| 401 | + return fallback | ||
| 402 | +} | ||
| 403 | + | ||
| 404 | +func orgRepositoryPaginationPages(baseURL, query, typeFilter, language, sortKey string, page, pageCount int) []orgRepositoryPageLink { | ||
| 405 | + if pageCount <= 1 { | ||
| 406 | + return nil | ||
| 407 | + } | ||
| 408 | + out := make([]orgRepositoryPageLink, 0, pageCount) | ||
| 409 | + for i := 1; i <= pageCount; i++ { | ||
| 410 | + out = append(out, orgRepositoryPageLink{ | ||
| 411 | + Number: i, | ||
| 412 | + Href: orgRepositoryURL(baseURL, query, typeFilter, language, sortKey, i), | ||
| 413 | + Current: i == page, | ||
| 414 | + }) | ||
| 415 | + } | ||
| 416 | + return out | ||
| 417 | +} | ||
internal/web/handlers/profile/profile.gomodified@@ -1,7 +1,8 @@ | |||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | 2 | ||
| 3 | // Package profile owns the read-only public profile handlers: | 3 | // Package profile owns the read-only public profile handlers: |
| 4 | -// /{username} and /avatars/{username}. Edit-profile is S10. | 4 | +// /{username}, /orgs/{org}/repositories, and /avatars/{username}. |
| 5 | +// Edit-profile is S10. | ||
| 5 | // | 6 | // |
| 6 | // Route ordering is critical here: the wildcard /{username} catches any | 7 | // Route ordering is critical here: the wildcard /{username} catches any |
| 7 | // path the chi router didn't already match. The reserved-name list is the | 8 | // path the chi router didn't already match. The reserved-name list is the |
internal/web/handlers/profile/profile_test.gomodified@@ -4,6 +4,7 @@ package profile_test | |||
| 4 | 4 | ||
| 5 | import ( | 5 | import ( |
| 6 | "context" | 6 | "context" |
| 7 | + "fmt" | ||
| 7 | "io" | 8 | "io" |
| 8 | "log/slog" | 9 | "log/slog" |
| 9 | "net/http" | 10 | "net/http" |
@@ -47,6 +48,7 @@ func setupProfileEnvWithStore(t *testing.T, objectStore storage.ObjectStore) *pr | |||
| 47 | "profile/view.html": {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}} 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 }}`)}, | 48 | "profile/view.html": {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}} 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 }}`)}, |
| 48 | "profile/suspended.html": {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)}, | 49 | "profile/suspended.html": {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)}, |
| 49 | "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 }}`)}, | 50 | "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 }}`)}, |
| 51 | + "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 }}`)}, | ||
| 50 | "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)}, | 52 | "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)}, |
| 51 | "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)}, | 53 | "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)}, |
| 52 | } | 54 | } |
@@ -87,6 +89,7 @@ func setupProfileEnvWithStore(t *testing.T, objectStore storage.ObjectStore) *pr | |||
| 87 | _, _ = w.Write([]byte("login-handler")) | 89 | _, _ = w.Write([]byte("login-handler")) |
| 88 | }) | 90 | }) |
| 89 | h.MountAvatars(r) | 91 | h.MountAvatars(r) |
| 92 | + h.MountOrgRepositories(r) | ||
| 90 | h.MountProfile(r) | 93 | h.MountProfile(r) |
| 91 | 94 | ||
| 92 | srv := httptest.NewServer(r) | 95 | srv := httptest.NewServer(r) |
@@ -359,6 +362,63 @@ func TestProfile_DispatchesOrgOverviewWithVisibleAggregates(t *testing.T) { | |||
| 359 | } | 362 | } |
| 360 | } | 363 | } |
| 361 | 364 | ||
| 365 | +func TestProfile_OrgRepositoriesPagePaginatesVisibleRepos(t *testing.T) { | ||
| 366 | + t.Parallel() | ||
| 367 | + env := setupProfileEnv(t) | ||
| 368 | + creator := env.insertUser(t, "alice", "Alice", "") | ||
| 369 | + orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", creator) | ||
| 370 | + for i := 0; i < 31; i++ { | ||
| 371 | + env.insertOrgRepo(t, orgID, fmt.Sprintf("repo-%02d", i), "public repo", "public", "Go", int64(i), 0) | ||
| 372 | + } | ||
| 373 | + env.insertOrgRepo(t, orgID, "private-roadmap", "hidden", "private", "Rust", 99, 0) | ||
| 374 | + | ||
| 375 | + body := env.getAs(t, "/orgs/tenseleyflow/repositories?sort=name&page=2", usersdb.User{}) | ||
| 376 | + for _, want := range []string{ | ||
| 377 | + "ORGREPOS=tenseleyflow", | ||
| 378 | + "ACTIVE=repositories", | ||
| 379 | + "TOTAL=31", | ||
| 380 | + "FILTERED=31", | ||
| 381 | + "PAGE=2/2", | ||
| 382 | + "SORT=name", | ||
| 383 | + "NAMES=repo-30;", | ||
| 384 | + "P1=false", | ||
| 385 | + "P2=true", | ||
| 386 | + } { | ||
| 387 | + if !strings.Contains(body, want) { | ||
| 388 | + t.Errorf("missing %q in body: %s", want, body) | ||
| 389 | + } | ||
| 390 | + } | ||
| 391 | + if strings.Contains(body, "private-roadmap") || strings.Contains(body, "Rust") { | ||
| 392 | + t.Fatalf("anonymous org repositories page leaked private repo data: %s", body) | ||
| 393 | + } | ||
| 394 | +} | ||
| 395 | + | ||
| 396 | +func TestProfile_OrgRepositoriesPageFilters(t *testing.T) { | ||
| 397 | + t.Parallel() | ||
| 398 | + env := setupProfileEnv(t) | ||
| 399 | + creator := env.insertUser(t, "alice", "Alice", "") | ||
| 400 | + orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", creator) | ||
| 401 | + env.insertOrgRepo(t, orgID, "shithub", "GitHub clone", "public", "Go", 3, 1, "forge") | ||
| 402 | + env.insertOrgRepo(t, orgID, "loader", "local agent loop", "public", "Python", 9, 0, "agents") | ||
| 403 | + env.insertOrgRepo(t, orgID, "sway", "adapter research", "public", "Python", 1, 0, "llm") | ||
| 404 | + | ||
| 405 | + body := env.getAs(t, "/orgs/tenseleyflow/repositories?q=agent&type=public&language=Python&sort=stars", usersdb.User{}) | ||
| 406 | + for _, want := range []string{ | ||
| 407 | + "FILTERED=1", | ||
| 408 | + "TYPE=public", | ||
| 409 | + "LANG=Python", | ||
| 410 | + "SORT=stars", | ||
| 411 | + "NAMES=loader;", | ||
| 412 | + } { | ||
| 413 | + if !strings.Contains(body, want) { | ||
| 414 | + t.Errorf("missing %q in body: %s", want, body) | ||
| 415 | + } | ||
| 416 | + } | ||
| 417 | + if strings.Contains(body, "shithub") || strings.Contains(body, "sway;") { | ||
| 418 | + t.Fatalf("org repositories filters returned unexpected repo: %s", body) | ||
| 419 | + } | ||
| 420 | +} | ||
| 421 | + | ||
| 362 | func TestProfile_UserPinsCanBeCustomized(t *testing.T) { | 422 | func TestProfile_UserPinsCanBeCustomized(t *testing.T) { |
| 363 | t.Parallel() | 423 | t.Parallel() |
| 364 | env := setupProfileEnv(t) | 424 | env := setupProfileEnv(t) |
internal/web/server.gomodified@@ -176,6 +176,7 @@ func Run(ctx context.Context, opts Options) error { | |||
| 176 | } | 176 | } |
| 177 | deps.AvatarMounter = profile.MountAvatars | 177 | deps.AvatarMounter = profile.MountAvatars |
| 178 | deps.ProfileMounter = profile.MountProfile | 178 | deps.ProfileMounter = profile.MountProfile |
| 179 | + deps.OrgRepositoriesMounter = profile.MountOrgRepositories | ||
| 179 | 180 | ||
| 180 | repoH, err := buildRepoHandlers(cfg, pool, deps.TemplatesFS, logger) | 181 | repoH, err := buildRepoHandlers(cfg, pool, deps.TemplatesFS, logger) |
| 181 | if err != nil { | 182 | if err != nil { |
internal/web/static/css/shithub.cssmodified@@ -2525,6 +2525,153 @@ code { | |||
| 2525 | color: var(--fg-default); | 2525 | color: var(--fg-default); |
| 2526 | font-size: 1rem; | 2526 | font-size: 1rem; |
| 2527 | } | 2527 | } |
| 2528 | +.shithub-org-repos-footer { | ||
| 2529 | + display: flex; | ||
| 2530 | + justify-content: center; | ||
| 2531 | + padding: 0.8rem 1rem; | ||
| 2532 | + border: 1px solid var(--border-default); | ||
| 2533 | + border-top: 0; | ||
| 2534 | + border-radius: 0 0 6px 6px; | ||
| 2535 | + background: var(--canvas-subtle); | ||
| 2536 | + font-size: 0.875rem; | ||
| 2537 | + font-weight: 600; | ||
| 2538 | +} | ||
| 2539 | +.shithub-org-repo-list + .shithub-org-repos-footer { | ||
| 2540 | + margin-top: -1px; | ||
| 2541 | +} | ||
| 2542 | +.shithub-filter-menu a.is-selected { | ||
| 2543 | + background: var(--canvas-subtle); | ||
| 2544 | + font-weight: 600; | ||
| 2545 | +} | ||
| 2546 | +.shithub-filter-count { | ||
| 2547 | + float: right; | ||
| 2548 | + margin-left: 1rem; | ||
| 2549 | + color: var(--fg-muted); | ||
| 2550 | + font-weight: 400; | ||
| 2551 | +} | ||
| 2552 | + | ||
| 2553 | +/* Organization repositories tab. GitHub's current route is | ||
| 2554 | + /orgs/{org}/repositories: compact pagehead, repo search, dropdown | ||
| 2555 | + filters, bordered rows, sparklines, and numbered pagination. */ | ||
| 2556 | +.shithub-org-repositories-page { | ||
| 2557 | + max-width: none; | ||
| 2558 | +} | ||
| 2559 | +.shithub-org-repositories-shell { | ||
| 2560 | + max-width: 1040px; | ||
| 2561 | + margin: 0 auto; | ||
| 2562 | + padding: 1.5rem 1rem 2rem; | ||
| 2563 | +} | ||
| 2564 | +.shithub-org-repositories-titlebar { | ||
| 2565 | + display: flex; | ||
| 2566 | + align-items: center; | ||
| 2567 | + justify-content: space-between; | ||
| 2568 | + gap: 1rem; | ||
| 2569 | + margin-bottom: 1rem; | ||
| 2570 | +} | ||
| 2571 | +.shithub-org-repositories-titlebar h1 { | ||
| 2572 | + margin: 0; | ||
| 2573 | + font-size: 1.5rem; | ||
| 2574 | + line-height: 1.25; | ||
| 2575 | +} | ||
| 2576 | +.shithub-org-repositories-titlebar p { | ||
| 2577 | + margin: 0.25rem 0 0; | ||
| 2578 | + color: var(--fg-muted); | ||
| 2579 | + font-size: 0.875rem; | ||
| 2580 | +} | ||
| 2581 | +.shithub-org-repositories-toolbar { | ||
| 2582 | + display: grid; | ||
| 2583 | + grid-template-columns: minmax(240px, 1fr) auto; | ||
| 2584 | + gap: 0.75rem; | ||
| 2585 | + align-items: center; | ||
| 2586 | + padding-bottom: 1rem; | ||
| 2587 | + border-bottom: 1px solid var(--border-default); | ||
| 2588 | +} | ||
| 2589 | +.shithub-org-repositories-search { | ||
| 2590 | + position: relative; | ||
| 2591 | + min-width: 0; | ||
| 2592 | +} | ||
| 2593 | +.shithub-org-repositories-search > span { | ||
| 2594 | + position: absolute; | ||
| 2595 | + left: 0.7rem; | ||
| 2596 | + top: 50%; | ||
| 2597 | + display: inline-flex; | ||
| 2598 | + color: var(--fg-muted); | ||
| 2599 | + transform: translateY(-50%); | ||
| 2600 | + pointer-events: none; | ||
| 2601 | +} | ||
| 2602 | +.shithub-org-repositories-search input[type="search"] { | ||
| 2603 | + width: 100%; | ||
| 2604 | + min-height: 34px; | ||
| 2605 | + padding: 0.35rem 0.75rem 0.35rem 2rem; | ||
| 2606 | + border: 1px solid var(--border-default); | ||
| 2607 | + border-radius: 6px; | ||
| 2608 | + background: var(--canvas-default); | ||
| 2609 | + color: var(--fg-default); | ||
| 2610 | +} | ||
| 2611 | +.shithub-org-repositories-filters { | ||
| 2612 | + display: flex; | ||
| 2613 | + align-items: center; | ||
| 2614 | + justify-content: flex-end; | ||
| 2615 | + gap: 0.5rem; | ||
| 2616 | + flex-wrap: wrap; | ||
| 2617 | +} | ||
| 2618 | +.shithub-org-repositories-filters .shithub-filter-menu[open] > div { | ||
| 2619 | + min-width: 190px; | ||
| 2620 | +} | ||
| 2621 | +.shithub-org-repositories-filters .shithub-filter-menu summary span { | ||
| 2622 | + color: var(--fg-muted); | ||
| 2623 | + font-weight: 400; | ||
| 2624 | +} | ||
| 2625 | +.shithub-org-repositories-list { | ||
| 2626 | + margin-top: 0; | ||
| 2627 | + border-top: 0; | ||
| 2628 | + border-radius: 0 0 6px 6px; | ||
| 2629 | +} | ||
| 2630 | +.shithub-org-repositories-empty { | ||
| 2631 | + margin-top: 1rem; | ||
| 2632 | +} | ||
| 2633 | +.shithub-org-repositories-empty h2 { | ||
| 2634 | + margin: 0 0 0.55rem; | ||
| 2635 | + color: var(--fg-default); | ||
| 2636 | + font-size: 1.1rem; | ||
| 2637 | +} | ||
| 2638 | +.shithub-org-repositories-empty p { | ||
| 2639 | + margin: 0; | ||
| 2640 | +} | ||
| 2641 | +.shithub-org-repositories-pagination { | ||
| 2642 | + display: flex; | ||
| 2643 | + justify-content: center; | ||
| 2644 | + gap: 0.35rem; | ||
| 2645 | + padding: 1.25rem 0 0; | ||
| 2646 | +} | ||
| 2647 | +.shithub-org-repositories-pagination a, | ||
| 2648 | +.shithub-org-repositories-pagination span { | ||
| 2649 | + display: inline-flex; | ||
| 2650 | + align-items: center; | ||
| 2651 | + justify-content: center; | ||
| 2652 | + min-width: 34px; | ||
| 2653 | + min-height: 32px; | ||
| 2654 | + padding: 0.35rem 0.7rem; | ||
| 2655 | + border: 1px solid var(--border-default); | ||
| 2656 | + border-radius: 6px; | ||
| 2657 | + background: var(--canvas-default); | ||
| 2658 | + color: var(--fg-default); | ||
| 2659 | + font-size: 0.875rem; | ||
| 2660 | + font-weight: 600; | ||
| 2661 | +} | ||
| 2662 | +.shithub-org-repositories-pagination a:hover { | ||
| 2663 | + background: var(--canvas-subtle); | ||
| 2664 | + text-decoration: none; | ||
| 2665 | +} | ||
| 2666 | +.shithub-org-repositories-pagination .is-current { | ||
| 2667 | + border-color: var(--accent-emphasis); | ||
| 2668 | + background: var(--accent-emphasis); | ||
| 2669 | + color: #fff; | ||
| 2670 | +} | ||
| 2671 | +.shithub-org-repositories-pagination .is-disabled { | ||
| 2672 | + color: var(--fg-muted); | ||
| 2673 | + opacity: 0.65; | ||
| 2674 | +} | ||
| 2528 | 2675 | ||
| 2529 | /* Organization People page. Mirrors GitHub's compact org header, | 2676 | /* Organization People page. Mirrors GitHub's compact org header, |
| 2530 | permissions sidebar, toolbar search, and bordered member rows. */ | 2677 | permissions sidebar, toolbar search, and bordered member rows. */ |
@@ -3570,7 +3717,8 @@ code { | |||
| 3570 | .shithub-org-settings-layout, | 3717 | .shithub-org-settings-layout, |
| 3571 | .shithub-org-settings-profile-grid, | 3718 | .shithub-org-settings-profile-grid, |
| 3572 | .shithub-org-people-layout, | 3719 | .shithub-org-people-layout, |
| 3573 | - .shithub-org-repo-head { | 3720 | + .shithub-org-repo-head, |
| 3721 | + .shithub-org-repositories-toolbar { | ||
| 3574 | grid-template-columns: 1fr; | 3722 | grid-template-columns: 1fr; |
| 3575 | } | 3723 | } |
| 3576 | .shithub-org-avatar { | 3724 | .shithub-org-avatar { |
@@ -3578,7 +3726,8 @@ code { | |||
| 3578 | height: 80px; | 3726 | height: 80px; |
| 3579 | } | 3727 | } |
| 3580 | .shithub-org-hero-actions, | 3728 | .shithub-org-hero-actions, |
| 3581 | - .shithub-org-repo-actions { | 3729 | + .shithub-org-repo-actions, |
| 3730 | + .shithub-org-repositories-filters { | ||
| 3582 | justify-content: flex-start; | 3731 | justify-content: flex-start; |
| 3583 | } | 3732 | } |
| 3584 | .shithub-org-repo-row { | 3733 | .shithub-org-repo-row { |
internal/web/templates/orgs/profile.htmlmodified@@ -57,22 +57,21 @@ | |||
| 57 | <section class="shithub-org-repos" id="org-repositories" aria-labelledby="org-repositories-heading"> | 57 | <section class="shithub-org-repos" id="org-repositories" aria-labelledby="org-repositories-heading"> |
| 58 | <div class="shithub-org-repo-head"> | 58 | <div class="shithub-org-repo-head"> |
| 59 | <h2 id="org-repositories-heading">{{ octicon "repo" }} Repositories</h2> | 59 | <h2 id="org-repositories-heading">{{ octicon "repo" }} Repositories</h2> |
| 60 | - <form action="/search" method="get" role="search" class="shithub-org-repo-search"> | 60 | + <form action="{{ .OrgRepositoriesURL }}" method="get" role="search" class="shithub-org-repo-search"> |
| 61 | <input type="search" name="q" placeholder="Find a repository..." aria-label="Find a repository"> | 61 | <input type="search" name="q" placeholder="Find a repository..." aria-label="Find a repository"> |
| 62 | - <input type="hidden" name="type" value="repos"> | ||
| 63 | </form> | 62 | </form> |
| 64 | <div class="shithub-org-repo-actions"> | 63 | <div class="shithub-org-repo-actions"> |
| 65 | <details class="shithub-filter-menu"> | 64 | <details class="shithub-filter-menu"> |
| 66 | <summary>Type {{ octicon "triangle-down" }}</summary> | 65 | <summary>Type {{ octicon "triangle-down" }}</summary> |
| 67 | - <div><a href="#org-repositories">All</a><a href="#org-repositories">Public</a><a href="#org-repositories">Private</a></div> | 66 | + <div><a href="{{ .OrgRepositoriesURL }}">All</a><a href="{{ .OrgRepositoriesURL }}?type=public">Public</a><a href="{{ .OrgRepositoriesURL }}?type=source">Sources</a><a href="{{ .OrgRepositoriesURL }}?type=fork">Forks</a><a href="{{ .OrgRepositoriesURL }}?type=archived">Archived</a></div> |
| 68 | </details> | 67 | </details> |
| 69 | <details class="shithub-filter-menu"> | 68 | <details class="shithub-filter-menu"> |
| 70 | <summary>Language {{ octicon "triangle-down" }}</summary> | 69 | <summary>Language {{ octicon "triangle-down" }}</summary> |
| 71 | - <div><a href="#org-repositories">All</a>{{ range .TopLanguages }}<a href="#org-repositories">{{ .Name }}</a>{{ end }}</div> | 70 | + <div><a href="{{ .OrgRepositoriesURL }}">All</a>{{ range .TopLanguages }}<a href="{{ $.OrgRepositoriesURL }}?language={{ urlquery .Name }}">{{ .Name }}</a>{{ end }}</div> |
| 72 | </details> | 71 | </details> |
| 73 | <details class="shithub-filter-menu"> | 72 | <details class="shithub-filter-menu"> |
| 74 | <summary>Sort {{ octicon "triangle-down" }}</summary> | 73 | <summary>Sort {{ octicon "triangle-down" }}</summary> |
| 75 | - <div><a href="#org-repositories">Last updated</a><a href="#org-repositories">Name</a><a href="#org-repositories">Stars</a></div> | 74 | + <div><a href="{{ .OrgRepositoriesURL }}">Last updated</a><a href="{{ .OrgRepositoriesURL }}?sort=name">Name</a><a href="{{ .OrgRepositoriesURL }}?sort=stars">Stars</a></div> |
| 76 | </details> | 75 | </details> |
| 77 | {{ if .CanCreateRepo }}<a href="/new?owner={{ .Org.Slug }}" class="shithub-button shithub-button-primary">{{ octicon "repo" }} New</a>{{ end }} | 76 | {{ if .CanCreateRepo }}<a href="/new?owner={{ .Org.Slug }}" class="shithub-button shithub-button-primary">{{ octicon "repo" }} New</a>{{ end }} |
| 78 | </div> | 77 | </div> |
@@ -106,6 +105,11 @@ | |||
| 106 | </li> | 105 | </li> |
| 107 | {{ end }} | 106 | {{ end }} |
| 108 | </ul> | 107 | </ul> |
| 108 | + {{ if .HasMoreRepos }} | ||
| 109 | + <div class="shithub-org-repos-footer"> | ||
| 110 | + <a href="{{ .OrgRepositoriesURL }}">View all repositories</a> | ||
| 111 | + </div> | ||
| 112 | + {{ end }} | ||
| 109 | {{ else }} | 113 | {{ else }} |
| 110 | <div class="shithub-org-empty"> | 114 | <div class="shithub-org-empty"> |
| 111 | <h3>No repositories yet.</h3> | 115 | <h3>No repositories yet.</h3> |
internal/web/templates/orgs/repositories.htmladded@@ -0,0 +1,117 @@ | |||
| 1 | +{{ define "page" -}} | ||
| 2 | +<section class="shithub-org-repositories-page"> | ||
| 3 | + <header class="shithub-org-pagehead"> | ||
| 4 | + <div class="shithub-org-pagehead-inner"> | ||
| 5 | + <a class="shithub-org-pagehead-title" href="/{{ .Org.Slug }}"> | ||
| 6 | + <img src="{{ .AvatarURL }}" alt="" width="30" height="30"> | ||
| 7 | + <span>{{ if .Org.DisplayName }}{{ .Org.DisplayName }}{{ else }}{{ .Org.Slug }}{{ end }}</span> | ||
| 8 | + </a> | ||
| 9 | + </div> | ||
| 10 | + {{ template "org-subnav" . }} | ||
| 11 | + </header> | ||
| 12 | + | ||
| 13 | + <main class="shithub-org-repositories-shell"> | ||
| 14 | + <div class="shithub-org-repositories-titlebar"> | ||
| 15 | + <div> | ||
| 16 | + <h1>Repositories</h1> | ||
| 17 | + <p> | ||
| 18 | + {{ if .HasActiveFilters }} | ||
| 19 | + {{ .FilteredCount }} result{{ if ne .FilteredCount 1 }}s{{ end }} | ||
| 20 | + {{ else }} | ||
| 21 | + {{ .RepoCount }} repositor{{ if eq .RepoCount 1 }}y{{ else }}ies{{ end }} | ||
| 22 | + {{ end }} | ||
| 23 | + </p> | ||
| 24 | + </div> | ||
| 25 | + {{ if .CanCreateRepo }}<a href="/new?owner={{ .Org.Slug }}" class="shithub-button shithub-button-primary">{{ octicon "repo" }} New</a>{{ end }} | ||
| 26 | + </div> | ||
| 27 | + | ||
| 28 | + <div class="shithub-org-repositories-toolbar"> | ||
| 29 | + <form action="/orgs/{{ .Org.Slug }}/repositories" method="get" role="search" class="shithub-org-repositories-search"> | ||
| 30 | + <span aria-hidden="true">{{ octicon "search" }}</span> | ||
| 31 | + <label class="sr-only" for="org-repositories-search">Find a repository</label> | ||
| 32 | + <input id="org-repositories-search" type="search" name="q" value="{{ .Query }}" placeholder="Find a repository..."> | ||
| 33 | + {{ if ne .SelectedType "all" }}<input type="hidden" name="type" value="{{ .SelectedType }}">{{ end }} | ||
| 34 | + {{ if .SelectedLanguage }}<input type="hidden" name="language" value="{{ .SelectedLanguage }}">{{ end }} | ||
| 35 | + {{ if ne .SelectedSort "updated" }}<input type="hidden" name="sort" value="{{ .SelectedSort }}">{{ end }} | ||
| 36 | + </form> | ||
| 37 | + | ||
| 38 | + <div class="shithub-org-repositories-filters"> | ||
| 39 | + <details class="shithub-filter-menu"> | ||
| 40 | + <summary>Type: <span>{{ .SelectedTypeLabel }}</span> {{ octicon "triangle-down" }}</summary> | ||
| 41 | + <div> | ||
| 42 | + {{ range .TypeFilters }} | ||
| 43 | + <a href="{{ .Href }}" class="{{ if .Selected }}is-selected{{ end }}">{{ .Label }} <span class="shithub-filter-count">{{ .Count }}</span></a> | ||
| 44 | + {{ end }} | ||
| 45 | + </div> | ||
| 46 | + </details> | ||
| 47 | + <details class="shithub-filter-menu"> | ||
| 48 | + <summary>Language: <span>{{ .SelectedLanguageLabel }}</span> {{ octicon "triangle-down" }}</summary> | ||
| 49 | + <div> | ||
| 50 | + {{ range .LanguageFilters }} | ||
| 51 | + <a href="{{ .Href }}" class="{{ if .Selected }}is-selected{{ end }}">{{ .Label }} <span class="shithub-filter-count">{{ .Count }}</span></a> | ||
| 52 | + {{ end }} | ||
| 53 | + </div> | ||
| 54 | + </details> | ||
| 55 | + <details class="shithub-filter-menu"> | ||
| 56 | + <summary>Sort: <span>{{ .SelectedSortLabel }}</span> {{ octicon "triangle-down" }}</summary> | ||
| 57 | + <div> | ||
| 58 | + {{ range .SortOptions }} | ||
| 59 | + <a href="{{ .Href }}" class="{{ if .Selected }}is-selected{{ end }}">{{ .Label }}</a> | ||
| 60 | + {{ end }} | ||
| 61 | + </div> | ||
| 62 | + </details> | ||
| 63 | + </div> | ||
| 64 | + </div> | ||
| 65 | + | ||
| 66 | + {{ if .Repos }} | ||
| 67 | + <ul class="shithub-org-repo-list shithub-org-repositories-list"> | ||
| 68 | + {{ range .Repos }} | ||
| 69 | + <li class="shithub-org-repo-row"> | ||
| 70 | + <div class="shithub-org-repo-row-main"> | ||
| 71 | + <h3> | ||
| 72 | + <a href="/{{ $.Org.Slug }}/{{ .Name }}">{{ .Name }}</a> | ||
| 73 | + {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }} | ||
| 74 | + {{ if .IsArchived }}<span class="shithub-pill shithub-pill-archived">Archived</span>{{ end }} | ||
| 75 | + </h3> | ||
| 76 | + {{ if .Description }}<p>{{ .Description }}</p>{{ end }} | ||
| 77 | + {{ if .Topics }} | ||
| 78 | + <div class="shithub-org-row-topics"> | ||
| 79 | + {{ range .Topics }}<a href="/search?q=topic:{{ . }}&type=repositories" class="shithub-topic">{{ . }}</a>{{ end }} | ||
| 80 | + </div> | ||
| 81 | + {{ end }} | ||
| 82 | + <div class="shithub-org-repo-meta"> | ||
| 83 | + {{ if .PrimaryLanguage }}<span><span class="shithub-language-dot" style="background-color: {{ .PrimaryLanguageColor }};"></span>{{ .PrimaryLanguage }}</span>{{ end }} | ||
| 84 | + {{ if .LicenseKey }}<span>{{ octicon "law" }} {{ .LicenseKey }}</span>{{ end }} | ||
| 85 | + <span>{{ octicon "star" }} {{ .StarCount }}</span> | ||
| 86 | + <span>{{ octicon "repo-forked" }} {{ .ForkCount }}</span> | ||
| 87 | + <time datetime="{{ .UpdatedAt.Format "2006-01-02T15:04:05Z" }}">Updated {{ relativeTime .UpdatedAt }}</time> | ||
| 88 | + </div> | ||
| 89 | + </div> | ||
| 90 | + {{ .ActivitySparkline }} | ||
| 91 | + </li> | ||
| 92 | + {{ end }} | ||
| 93 | + </ul> | ||
| 94 | + {{ else }} | ||
| 95 | + <div class="shithub-org-empty shithub-org-repositories-empty"> | ||
| 96 | + {{ if .HasActiveFilters }} | ||
| 97 | + <h2>No repositories matched your search.</h2> | ||
| 98 | + <p>Try another search term or a different filter.</p> | ||
| 99 | + {{ else }} | ||
| 100 | + <h2>No repositories yet.</h2> | ||
| 101 | + {{ if .CanCreateRepo }}<a href="/new?owner={{ .Org.Slug }}" class="shithub-button shithub-button-primary">Create a repository</a>{{ end }} | ||
| 102 | + {{ end }} | ||
| 103 | + </div> | ||
| 104 | + {{ end }} | ||
| 105 | + | ||
| 106 | + {{ if gt .PageCount 1 }} | ||
| 107 | + <nav class="shithub-org-repositories-pagination" aria-label="Pagination"> | ||
| 108 | + {{ if .HasPrev }}<a href="{{ .PrevHref }}">Previous</a>{{ else }}<span class="is-disabled" aria-disabled="true">Previous</span>{{ end }} | ||
| 109 | + {{ range .PaginationPages }} | ||
| 110 | + {{ if .Current }}<span class="is-current" aria-current="page">{{ .Number }}</span>{{ else }}<a href="{{ .Href }}">{{ .Number }}</a>{{ end }} | ||
| 111 | + {{ end }} | ||
| 112 | + {{ if .HasNext }}<a href="{{ .NextHref }}">Next</a>{{ else }}<span class="is-disabled" aria-disabled="true">Next</span>{{ end }} | ||
| 113 | + </nav> | ||
| 114 | + {{ end }} | ||
| 115 | + </main> | ||
| 116 | +</section> | ||
| 117 | +{{- end }} | ||
internal/web/templates/orgs/settings_profile.htmlmodified@@ -9,7 +9,7 @@ | |||
| 9 | </div> | 9 | </div> |
| 10 | <nav class="shithub-org-nav" aria-label="Organization"> | 10 | <nav class="shithub-org-nav" aria-label="Organization"> |
| 11 | <a href="/{{ .Org.Slug }}" class="shithub-org-nav-item">{{ octicon "home" }} Overview</a> | 11 | <a href="/{{ .Org.Slug }}" class="shithub-org-nav-item">{{ octicon "home" }} Overview</a> |
| 12 | - <a href="/{{ .Org.Slug }}#org-repositories" class="shithub-org-nav-item">{{ octicon "repo" }} Repositories</a> | 12 | + <a href="/orgs/{{ .Org.Slug }}/repositories" class="shithub-org-nav-item">{{ octicon "repo" }} Repositories</a> |
| 13 | <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "table" }} Projects</span> | 13 | <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "table" }} Projects</span> |
| 14 | <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "package" }} Packages</span> | 14 | <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "package" }} Packages</span> |
| 15 | <a href="/{{ .Org.Slug }}/teams" class="shithub-org-nav-item">{{ octicon "people" }} Teams</a> | 15 | <a href="/{{ .Org.Slug }}/teams" class="shithub-org-nav-item">{{ octicon "people" }} Teams</a> |