tenseleyflow/shithub / 392e063

Browse files

Add org repositories page

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
392e06389c60cfcf49fd950956b8d3b1d9b7ddbd
Parents
9ecd8ed
Tree
3c426f5

15 changed files

StatusFile+-
M docs/internal/orgs.md 8 3
M internal/web/handlers/handlers.go 7 0
M internal/web/handlers/orgs/orgs.go 1 0
M internal/web/handlers/profile/org_profile.go 26 24
A internal/web/handlers/profile/org_repositories.go 417 0
M internal/web/handlers/profile/profile.go 2 1
M internal/web/handlers/profile/profile_test.go 60 0
M internal/web/nav_test.go 1 1
M internal/web/server.go 1 0
M internal/web/static/css/shithub.css 151 2
M internal/web/templates/_nav_offcanvas.html 2 2
M internal/web/templates/_org_subnav.html 1 1
M internal/web/templates/orgs/profile.html 9 5
A internal/web/templates/orgs/repositories.html 117 0
M internal/web/templates/orgs/settings_profile.html 1 1
docs/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/nav_test.gomodified
@@ -80,7 +80,7 @@ func TestNavRendersContextualRepoAndOrgHeaders(t *testing.T) {
80
 				`role="dialog" aria-modal="true" aria-label="Global navigation"`,
80
 				`role="dialog" aria-modal="true" aria-label="Global navigation"`,
81
 				`aria-label="Organization"`,
81
 				`aria-label="Organization"`,
82
 				`href="/tenseleyFlow" class="is-strong">tenseleyFlow</a>`,
82
 				`href="/tenseleyFlow" class="is-strong">tenseleyFlow</a>`,
83
-				`href="/tenseleyFlow#org-repositories" class="shithub-offcanvas-repo-item"`,
83
+				`href="/orgs/tenseleyFlow/repositories" class="shithub-offcanvas-repo-item"`,
84
 				`href="/tenseleyFlow/teams"`,
84
 				`href="/tenseleyFlow/teams"`,
85
 				`href="/tenseleyFlow/people"`,
85
 				`href="/tenseleyFlow/people"`,
86
 			},
86
 			},
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/_nav_offcanvas.htmlmodified
@@ -35,7 +35,7 @@
35
           <span>{{ .Owner }}/{{ .Repo.Name }}</span>
35
           <span>{{ .Owner }}/{{ .Repo.Name }}</span>
36
         </a>
36
         </a>
37
         {{ else if .Org }}
37
         {{ else if .Org }}
38
-        <a href="/{{ .Org.Slug }}#org-repositories" class="shithub-offcanvas-repo-item">
38
+        <a href="/orgs/{{ .Org.Slug }}/repositories" class="shithub-offcanvas-repo-item">
39
           <img src="/avatars/{{ .Org.Slug }}" alt="" width="20" height="20">
39
           <img src="/avatars/{{ .Org.Slug }}" alt="" width="20" height="20">
40
           <span>{{ .Org.Slug }} repositories</span>
40
           <span>{{ .Org.Slug }} repositories</span>
41
         </a>
41
         </a>
@@ -49,7 +49,7 @@
49
       {{ if .Repo }}
49
       {{ if .Repo }}
50
       <a href="/{{ .Owner }}?tab=repositories" class="shithub-offcanvas-show-more">Show more</a>
50
       <a href="/{{ .Owner }}?tab=repositories" class="shithub-offcanvas-show-more">Show more</a>
51
       {{ else if .Org }}
51
       {{ else if .Org }}
52
-      <a href="/{{ .Org.Slug }}#org-repositories" class="shithub-offcanvas-show-more">Show more</a>
52
+      <a href="/orgs/{{ .Org.Slug }}/repositories" class="shithub-offcanvas-show-more">Show more</a>
53
       {{ else if .Viewer.ID }}
53
       {{ else if .Viewer.ID }}
54
       <a href="/{{ .Viewer.Username }}?tab=repositories" class="shithub-offcanvas-show-more">Show more</a>
54
       <a href="/{{ .Viewer.Username }}?tab=repositories" class="shithub-offcanvas-show-more">Show more</a>
55
       {{ end }}
55
       {{ end }}
internal/web/templates/_org_subnav.htmlmodified
@@ -3,7 +3,7 @@
3
   <a href="/{{ .Org.Slug }}" class="shithub-org-nav-item{{ if eq .ActiveOrgNav "overview" }} is-active{{ end }}">
3
   <a href="/{{ .Org.Slug }}" class="shithub-org-nav-item{{ if eq .ActiveOrgNav "overview" }} is-active{{ end }}">
4
     {{ octicon "home" }} Overview
4
     {{ octicon "home" }} Overview
5
   </a>
5
   </a>
6
-  <a href="/{{ .Org.Slug }}#org-repositories" class="shithub-org-nav-item{{ if eq .ActiveOrgNav "repositories" }} is-active{{ end }}">
6
+  <a href="/orgs/{{ .Org.Slug }}/repositories" class="shithub-org-nav-item{{ if eq .ActiveOrgNav "repositories" }} is-active{{ end }}">
7
     {{ octicon "repo" }} Repositories
7
     {{ octicon "repo" }} Repositories
8
     {{ with .RepoCount }}<span class="shithub-tab-count">{{ . }}</span>{{ end }}
8
     {{ with .RepoCount }}<span class="shithub-tab-count">{{ . }}</span>{{ end }}
9
   </a>
9
   </a>
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:{{ . }}&amp;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>