tenseleyflow/shithub / d882795

Browse files

Align org overview homepage

Authored by espadonne
SHA
d882795e7ebb52d85de562f301c527d0a8167080
Parents
1263f26
Tree
3ae79ae

6 changed files

StatusFile+-
A internal/web/handlers/profile/org_profile.go 333 0
M internal/web/handlers/profile/profile.go 0 80
M internal/web/handlers/profile/profile_test.go 82 0
M internal/web/render/octicons.go 10 0
M internal/web/static/css/shithub.css 383 0
M internal/web/templates/orgs/profile.html 168 25
internal/web/handlers/profile/org_profile.goadded
@@ -0,0 +1,333 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package profile
4
+
5
+import (
6
+	"context"
7
+	"html/template"
8
+	"net/http"
9
+	"net/url"
10
+	"sort"
11
+	"time"
12
+
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+	"github.com/tenseleyFlow/shithub/internal/orgs"
17
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
18
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
+)
21
+
22
+const (
23
+	orgHomepageRepoLimit   = 10
24
+	orgHomepagePinnedLimit = 6
25
+	orgHomepagePeopleLimit = 8
26
+)
27
+
28
+type orgProfileRepo struct {
29
+	ID                   int64
30
+	Name                 string
31
+	Description          string
32
+	Visibility           string
33
+	IsArchived           bool
34
+	IsFork               bool
35
+	PrimaryLanguage      string
36
+	PrimaryLanguageColor template.CSS
37
+	LicenseKey           string
38
+	StarCount            int64
39
+	ForkCount            int64
40
+	UpdatedAt            time.Time
41
+	Topics               []string
42
+}
43
+
44
+type orgProfilePerson struct {
45
+	Username    string
46
+	DisplayName string
47
+	Role        string
48
+	AvatarURL   string
49
+}
50
+
51
+type orgProfileLanguage struct {
52
+	Name    string
53
+	Color   template.CSS
54
+	Count   int
55
+	Percent int
56
+}
57
+
58
+type orgProfileTopic struct {
59
+	Name  string
60
+	Count int
61
+}
62
+
63
+// serveOrgProfile renders /{org}. It mirrors GitHub's organization
64
+// overview shape: org nav, pinned repo cards, recent repo rows, and a
65
+// right rail with people/language/topic aggregates.
66
+func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID int64) {
67
+	ctx := r.Context()
68
+	q := orgsdb.New()
69
+	org, err := q.GetOrgByID(ctx, h.d.Pool, orgID)
70
+	if err != nil {
71
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
72
+		return
73
+	}
74
+	if org.DeletedAt.Valid {
75
+		// Soft-deleted orgs render the same "unavailable" shell as
76
+		// suspended/deleted users so the existence-leak posture is
77
+		// uniform.
78
+		h.renderUnavailable(w, r, string(org.Slug))
79
+		return
80
+	}
81
+
82
+	viewer := middleware.CurrentUserFromContext(r.Context())
83
+	isOwner := false
84
+	isMember := false
85
+	if !viewer.IsAnonymous() {
86
+		deps := orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}
87
+		isOwner, _ = orgs.IsOwner(ctx, deps, org.ID, viewer.ID)
88
+		isMember, _ = orgs.IsMember(ctx, deps, org.ID, viewer.ID)
89
+	}
90
+
91
+	repos := h.orgProfileRepos(ctx, org.ID, viewer)
92
+	people := h.orgProfilePeople(ctx, q, org.ID)
93
+	memberCount := h.orgMemberCount(ctx, org.ID)
94
+	viewAs := "Public"
95
+	if isOwner {
96
+		viewAs = "Owner"
97
+	} else if isMember {
98
+		viewAs = "Member"
99
+	}
100
+
101
+	avatarURL := "/avatars/" + url.PathEscape(org.Slug)
102
+	data := map[string]any{
103
+		"Title":         org.DisplayName,
104
+		"OGTitle":       org.DisplayName,
105
+		"OGDescription": org.Description,
106
+		"OGImage":       avatarURL,
107
+		"Org":           org,
108
+		"AvatarURL":     avatarURL,
109
+		"WebsiteSafe":   safeWebsite(org.Website),
110
+		"Repos":         limitOrgRepos(repos, orgHomepageRepoLimit),
111
+		"PinnedRepos":   pinnedOrgRepos(repos),
112
+		"RepoCount":     int64(len(repos)),
113
+		"MemberCount":   memberCount,
114
+		"People":        limitOrgPeople(people, orgHomepagePeopleLimit),
115
+		"TopLanguages":  orgTopLanguages(repos),
116
+		"TopTopics":     orgTopTopics(repos),
117
+		"ViewAs":        viewAs,
118
+		"IsOwner":       isOwner,
119
+		"IsMember":      isMember,
120
+		"CanCreateRepo": isOwner || (isMember && org.AllowMemberRepoCreate),
121
+	}
122
+	if isMember {
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/profile", data); err != nil {
128
+		h.d.Logger.ErrorContext(ctx, "orgs profile: render", "error", err)
129
+	}
130
+}
131
+
132
+func (h *Handlers) orgProfileRepos(ctx context.Context, orgID int64, viewer middleware.CurrentUser) []orgProfileRepo {
133
+	rows, err := reposdb.New().ListReposForOwnerOrg(ctx, h.d.Pool, pgtype.Int8{Int64: orgID, Valid: true})
134
+	if err != nil {
135
+		h.d.Logger.ErrorContext(ctx, "orgs profile: list repos", "error", err)
136
+		return nil
137
+	}
138
+	actor := policy.AnonymousActor()
139
+	if !viewer.IsAnonymous() {
140
+		actor = policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, viewer.IsSiteAdmin)
141
+		if viewer.ImpersonatedUserID != 0 {
142
+			actor.Impersonating = true
143
+			actor.ImpersonateWriteOK = viewer.ImpersonateWriteOK
144
+		}
145
+	}
146
+	deps := policy.Deps{Pool: h.d.Pool}
147
+
148
+	out := make([]orgProfileRepo, 0, len(rows))
149
+	for _, row := range rows {
150
+		if !policy.IsVisibleTo(ctx, deps, actor, policy.NewRepoRefFromRepo(row)) {
151
+			continue
152
+		}
153
+		item := orgProfileRepo{
154
+			ID:              row.ID,
155
+			Name:            string(row.Name),
156
+			Description:     row.Description,
157
+			Visibility:      string(row.Visibility),
158
+			IsArchived:      row.IsArchived,
159
+			IsFork:          row.ForkOfRepoID.Valid,
160
+			LicenseKey:      pgTextStringOrEmpty(row.LicenseKey),
161
+			StarCount:       row.StarCount,
162
+			ForkCount:       row.ForkCount,
163
+			UpdatedAt:       row.UpdatedAt.Time,
164
+			Topics:          h.orgRepoTopics(ctx, row.ID),
165
+			PrimaryLanguage: pgTextStringOrEmpty(row.PrimaryLanguage),
166
+		}
167
+		item.PrimaryLanguageColor = template.CSS(orgLanguageColor(item.PrimaryLanguage)) //nolint:gosec // CSS value comes from server-side constants.
168
+		out = append(out, item)
169
+	}
170
+	return out
171
+}
172
+
173
+func (h *Handlers) orgRepoTopics(ctx context.Context, repoID int64) []string {
174
+	topics, err := reposdb.New().ListRepoTopics(ctx, h.d.Pool, repoID)
175
+	if err != nil {
176
+		h.d.Logger.WarnContext(ctx, "orgs profile: list repo topics", "repo_id", repoID, "error", err)
177
+		return nil
178
+	}
179
+	return topics
180
+}
181
+
182
+func (h *Handlers) orgProfilePeople(ctx context.Context, q *orgsdb.Queries, orgID int64) []orgProfilePerson {
183
+	rows, err := q.ListOrgMembers(ctx, h.d.Pool, orgID)
184
+	if err != nil {
185
+		h.d.Logger.WarnContext(ctx, "orgs profile: list people", "org_id", orgID, "error", err)
186
+		return nil
187
+	}
188
+	out := make([]orgProfilePerson, 0, len(rows))
189
+	for _, row := range rows {
190
+		out = append(out, orgProfilePerson{
191
+			Username:    row.Username,
192
+			DisplayName: row.DisplayName,
193
+			Role:        string(row.Role),
194
+			AvatarURL:   "/avatars/" + url.PathEscape(row.Username),
195
+		})
196
+	}
197
+	return out
198
+}
199
+
200
+func (h *Handlers) orgMemberCount(ctx context.Context, orgID int64) int64 {
201
+	var n int64
202
+	_ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM org_members WHERE org_id = $1`, orgID).Scan(&n)
203
+	return n
204
+}
205
+
206
+func limitOrgRepos(repos []orgProfileRepo, limit int) []orgProfileRepo {
207
+	if len(repos) <= limit {
208
+		return repos
209
+	}
210
+	return repos[:limit]
211
+}
212
+
213
+func pinnedOrgRepos(repos []orgProfileRepo) []orgProfileRepo {
214
+	pinned := append([]orgProfileRepo(nil), repos...)
215
+	sort.SliceStable(pinned, func(i, j int) bool {
216
+		if pinned[i].StarCount != pinned[j].StarCount {
217
+			return pinned[i].StarCount > pinned[j].StarCount
218
+		}
219
+		return pinned[i].UpdatedAt.After(pinned[j].UpdatedAt)
220
+	})
221
+	return limitOrgRepos(pinned, orgHomepagePinnedLimit)
222
+}
223
+
224
+func limitOrgPeople(people []orgProfilePerson, limit int) []orgProfilePerson {
225
+	if len(people) <= limit {
226
+		return people
227
+	}
228
+	return people[:limit]
229
+}
230
+
231
+func orgTopLanguages(repos []orgProfileRepo) []orgProfileLanguage {
232
+	counts := map[string]int{}
233
+	for _, repo := range repos {
234
+		if repo.PrimaryLanguage == "" {
235
+			continue
236
+		}
237
+		counts[repo.PrimaryLanguage]++
238
+	}
239
+	total := 0
240
+	for _, n := range counts {
241
+		total += n
242
+	}
243
+	out := make([]orgProfileLanguage, 0, len(counts))
244
+	for name, n := range counts {
245
+		percent := 0
246
+		if total > 0 {
247
+			percent = int(float64(n) / float64(total) * 100)
248
+			if percent == 0 {
249
+				percent = 1
250
+			}
251
+		}
252
+		out = append(out, orgProfileLanguage{
253
+			Name:    name,
254
+			Color:   template.CSS(orgLanguageColor(name)), //nolint:gosec // CSS value comes from server-side constants.
255
+			Count:   n,
256
+			Percent: percent,
257
+		})
258
+	}
259
+	sort.SliceStable(out, func(i, j int) bool {
260
+		if out[i].Count != out[j].Count {
261
+			return out[i].Count > out[j].Count
262
+		}
263
+		return out[i].Name < out[j].Name
264
+	})
265
+	if len(out) > 5 {
266
+		return out[:5]
267
+	}
268
+	return out
269
+}
270
+
271
+func orgTopTopics(repos []orgProfileRepo) []orgProfileTopic {
272
+	counts := map[string]int{}
273
+	for _, repo := range repos {
274
+		for _, topic := range repo.Topics {
275
+			counts[topic]++
276
+		}
277
+	}
278
+	out := make([]orgProfileTopic, 0, len(counts))
279
+	for name, n := range counts {
280
+		out = append(out, orgProfileTopic{Name: name, Count: n})
281
+	}
282
+	sort.SliceStable(out, func(i, j int) bool {
283
+		if out[i].Count != out[j].Count {
284
+			return out[i].Count > out[j].Count
285
+		}
286
+		return out[i].Name < out[j].Name
287
+	})
288
+	if len(out) > 8 {
289
+		return out[:8]
290
+	}
291
+	return out
292
+}
293
+
294
+func orgLanguageColor(name string) string {
295
+	switch name {
296
+	case "Go":
297
+		return "#00add8"
298
+	case "HTML":
299
+		return "#e34c26"
300
+	case "CSS":
301
+		return "#663399"
302
+	case "Shell":
303
+		return "#89e051"
304
+	case "PLpgSQL":
305
+		return "#336790"
306
+	case "Jinja":
307
+		return "#a52a22"
308
+	case "JavaScript":
309
+		return "#f1e05a"
310
+	case "TypeScript":
311
+		return "#3178c6"
312
+	case "Python":
313
+		return "#3572a5"
314
+	case "Java":
315
+		return "#b07219"
316
+	case "Rust":
317
+		return "#dea584"
318
+	case "Ruby":
319
+		return "#701516"
320
+	case "PHP":
321
+		return "#4f5d95"
322
+	case "C":
323
+		return "#555555"
324
+	case "C++":
325
+		return "#f34b7d"
326
+	case "Makefile":
327
+		return "#427819"
328
+	case "Dockerfile":
329
+		return "#384d54"
330
+	default:
331
+		return "#ededed"
332
+	}
333
+}
internal/web/handlers/profile/profile.gomodified
@@ -29,8 +29,6 @@ import (
2929
 	"github.com/tenseleyFlow/shithub/internal/avatars"
3030
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
3131
 	"github.com/tenseleyFlow/shithub/internal/orgs"
32
-	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
33
-	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
3432
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
3533
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
3634
 	"github.com/tenseleyFlow/shithub/internal/web/render"
@@ -290,81 +288,3 @@ func safeWebsite(s string) template.URL {
290288
 // ensure context import is used by static analysis even if a future
291289
 // refactor removes its only inline use.
292290
 var _ = context.Background
293
-
294
-// serveOrgProfile renders /{org}. Pulls the org row + a small set of
295
-// the org's visible repos. Visibility scoping defers to the caller's
296
-// authentication state — a viewer that isn't a member sees only
297
-// public repos.
298
-func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID int64) {
299
-	ctx := r.Context()
300
-	org, err := orgsdb.New().GetOrgByID(ctx, h.d.Pool, orgID)
301
-	if err != nil {
302
-		h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
303
-		return
304
-	}
305
-	if org.DeletedAt.Valid {
306
-		// Soft-deleted orgs render the same "unavailable" shell as
307
-		// suspended/deleted users so the existence-leak posture is
308
-		// uniform.
309
-		h.renderUnavailable(w, r, string(org.Slug))
310
-		return
311
-	}
312
-	viewer := middleware.CurrentUserFromContext(r.Context())
313
-	isOwner := false
314
-	isMember := false
315
-	if !viewer.IsAnonymous() {
316
-		isOwner, _ = orgs.IsOwner(ctx, orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, org.ID, viewer.ID)
317
-		isMember, _ = orgs.IsMember(ctx, orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, org.ID, viewer.ID)
318
-	}
319
-
320
-	// Org repo listing — small inline query to avoid widening sqlc
321
-	// for one read. Members see private + public; non-members see
322
-	// public only. Soft-deleted repos are excluded uniformly.
323
-	visClause := "AND visibility = 'public'"
324
-	args := []any{org.ID}
325
-	if isMember {
326
-		visClause = ""
327
-	}
328
-	rows, err := h.d.Pool.Query(ctx,
329
-		`SELECT id, name, description, visibility::text
330
-		   FROM repos
331
-		  WHERE owner_org_id = $1 AND deleted_at IS NULL `+visClause+`
332
-		  ORDER BY name ASC LIMIT 50`,
333
-		args...)
334
-	if err != nil {
335
-		h.d.Logger.ErrorContext(ctx, "orgs profile: list repos", "error", err)
336
-	}
337
-	type repoRow struct {
338
-		Name, Description, Visibility string
339
-	}
340
-	var repos []repoRow
341
-	if rows != nil {
342
-		defer rows.Close()
343
-		for rows.Next() {
344
-			var id int64
345
-			var rr repoRow
346
-			if err := rows.Scan(&id, &rr.Name, &rr.Description, &rr.Visibility); err == nil {
347
-				repos = append(repos, rr)
348
-			}
349
-		}
350
-	}
351
-	memberCount := 0
352
-	{
353
-		var n int64
354
-		_ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM org_members WHERE org_id = $1`, org.ID).Scan(&n)
355
-		memberCount = int(n)
356
-	}
357
-
358
-	_ = h.d.Render.RenderPage(w, r, "orgs/profile", map[string]any{
359
-		"Title":       org.DisplayName,
360
-		"Org":         org,
361
-		"Repos":       repos,
362
-		"MemberCount": memberCount,
363
-		"IsOwner":     isOwner,
364
-		"IsMember":    isMember,
365
-	})
366
-}
367
-
368
-// avoid the unused-import lint when reposdb is only referenced in
369
-// the inline raw query above.
370
-var _ = reposdb.New
internal/web/handlers/profile/profile_test.gomodified
@@ -38,6 +38,7 @@ func setupProfileEnv(t *testing.T) *profileEnv {
3838
 		"hello.html":             {Data: []byte(`{{ define "page" }}home{{ end }}`)},
3939
 		"profile/view.html":      {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}}{{ end }}`)},
4040
 		"profile/suspended.html": {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)},
41
+		"orgs/profile.html":      {Data: []byte(`{{ define "page" }}ORG={{.Org.Slug}} REPOS={{len .Repos}} PINS={{len .PinnedRepos}} MEMBERS={{.MemberCount}} PEOPLE={{len .People}} NAMES={{range .Repos}}{{.Name}};{{end}} LANGS={{range .TopLanguages}}{{.Name}}={{.Count}};{{end}} TOPICS={{range .TopTopics}}{{.Name}}={{.Count}};{{end}} VIEWAS={{.ViewAs}}{{ end }}`)},
4142
 		"errors/404.html":        {Data: []byte(`{{ define "page" }}404{{ end }}`)},
4243
 		"errors/500.html":        {Data: []byte(`{{ define "page" }}500{{ end }}`)},
4344
 	}
@@ -90,6 +91,49 @@ func (e *profileEnv) insertUser(t *testing.T, username, display, bio string) use
9091
 	return user
9192
 }
9293
 
94
+func (e *profileEnv) insertOrg(t *testing.T, slug, display, desc string, creator usersdb.User) int64 {
95
+	t.Helper()
96
+	ctx := context.Background()
97
+	var orgID int64
98
+	if err := e.pool.QueryRow(ctx,
99
+		`INSERT INTO orgs (slug, display_name, description, created_by_user_id)
100
+		 VALUES ($1, $2, $3, $4)
101
+		 RETURNING id`,
102
+		slug, display, desc, creator.ID).Scan(&orgID); err != nil {
103
+		t.Fatalf("insert org: %v", err)
104
+	}
105
+	if _, err := e.pool.Exec(ctx,
106
+		`INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`,
107
+		orgID, creator.ID); err != nil {
108
+		t.Fatalf("insert org member: %v", err)
109
+	}
110
+	return orgID
111
+}
112
+
113
+func (e *profileEnv) insertOrgRepo(t *testing.T, orgID int64, name, desc, visibility, language string, stars, forks int64, topics ...string) int64 {
114
+	t.Helper()
115
+	ctx := context.Background()
116
+	var repoID int64
117
+	if err := e.pool.QueryRow(ctx,
118
+		`INSERT INTO repos (
119
+		    owner_org_id, name, description, visibility, default_branch,
120
+		    primary_language, star_count, fork_count, updated_at
121
+		  )
122
+		  VALUES ($1, $2, $3, $4, 'trunk', $5, $6, $7, now())
123
+		  RETURNING id`,
124
+		orgID, name, desc, visibility, language, stars, forks).Scan(&repoID); err != nil {
125
+		t.Fatalf("insert org repo: %v", err)
126
+	}
127
+	for _, topic := range topics {
128
+		if _, err := e.pool.Exec(ctx,
129
+			`INSERT INTO repo_topics (repo_id, topic) VALUES ($1, $2)`,
130
+			repoID, topic); err != nil {
131
+			t.Fatalf("insert topic: %v", err)
132
+		}
133
+	}
134
+	return repoID
135
+}
136
+
93137
 func (e *profileEnv) insertRedirect(t *testing.T, oldname string, userID int64) {
94138
 	t.Helper()
95139
 	if _, err := e.pool.Exec(context.Background(),
@@ -191,6 +235,44 @@ func TestProfile_UsernameRedirect(t *testing.T) {
191235
 	}
192236
 }
193237
 
238
+func TestProfile_DispatchesOrgOverviewWithVisibleAggregates(t *testing.T) {
239
+	t.Parallel()
240
+	env := setupProfileEnv(t)
241
+	creator := env.insertUser(t, "alice", "Alice", "")
242
+	orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", creator)
243
+	env.insertOrgRepo(t, orgID, "shithub", "GitHub clone", "public", "Go", 3, 1, "git", "forge")
244
+	env.insertOrgRepo(t, orgID, "private-roadmap", "hidden", "private", "Rust", 2, 0, "secret")
245
+
246
+	resp, err := newNonRedirClient(t).Get(env.srv.URL + "/tenseleyflow")
247
+	if err != nil {
248
+		t.Fatalf("GET: %v", err)
249
+	}
250
+	defer func() { _ = resp.Body.Close() }()
251
+	if resp.StatusCode != 200 {
252
+		t.Fatalf("status %d", resp.StatusCode)
253
+	}
254
+	body, _ := io.ReadAll(resp.Body)
255
+	got := string(body)
256
+	for _, want := range []string{
257
+		"ORG=tenseleyflow",
258
+		"REPOS=1",
259
+		"PINS=1",
260
+		"MEMBERS=1",
261
+		"PEOPLE=1",
262
+		"NAMES=shithub;",
263
+		"LANGS=Go=1;",
264
+		"TOPICS=forge=1;git=1;",
265
+		"VIEWAS=Public",
266
+	} {
267
+		if !strings.Contains(got, want) {
268
+			t.Errorf("missing %q in body: %s", want, got)
269
+		}
270
+	}
271
+	if strings.Contains(got, "private-roadmap") || strings.Contains(got, "Rust") {
272
+		t.Fatalf("anonymous org overview leaked private repo data: %s", got)
273
+	}
274
+}
275
+
194276
 func TestProfile_SuspendedRendersUnavailable(t *testing.T) {
195277
 	t.Parallel()
196278
 	env := setupProfileEnv(t)
internal/web/render/octicons.gomodified
@@ -35,6 +35,10 @@ func BuiltinOcticons() OcticonResolver {
3535
 			`><path d="M10.68 11.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.749.749 0 1 1-1.06 1.06ZM6.5 11.5a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z"/></svg>`),
3636
 		"repo": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
3737
 			`><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75A.75.75 0 0 1 14 .75v12.5a.75.75 0 0 1-.75.75H4.5a1 1 0 0 0 0 2h8.75a.75.75 0 0 1 0 1.5H4.5A2.5 2.5 0 0 1 2 15V2.5Zm2.5-1A1 1 0 0 0 3.5 2.5v10.21c.31-.13.648-.21 1-.21h8V1.5Z"/></svg>`),
38
+		"home": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
39
+			`><path d="M6.906.664a1.75 1.75 0 0 1 2.188 0l5.25 4.2c.414.331.656.833.656 1.363v7.023A1.75 1.75 0 0 1 13.25 15h-2.5A1.75 1.75 0 0 1 9 13.25V10H7v3.25A1.75 1.75 0 0 1 5.25 15h-2.5A1.75 1.75 0 0 1 1 13.25V6.227c0-.53.242-1.032.656-1.363Zm1.25 1.171a.25.25 0 0 0-.312 0l-5.25 4.2a.25.25 0 0 0-.094.192v7.023c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25V9.75A1.25 1.25 0 0 1 6.75 8.5h2.5a1.25 1.25 0 0 1 1.25 1.25v3.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25V6.227a.25.25 0 0 0-.094-.192Z"/></svg>`),
40
+		"table": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
41
+			`><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25ZM6.5 6.5v8h7.75a.25.25 0 0 0 .25-.25V6.5Zm8-1.5V1.75a.25.25 0 0 0-.25-.25H6.5V5Zm-13 1.5v7.75c0 .138.112.25.25.25H5v-8ZM5 5V1.5H1.75a.25.25 0 0 0-.25.25V5Z"/></svg>`),
3842
 		"code": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
3943
 			`><path d="M5.22 4.22a.75.75 0 0 1 1.06 1.06L3.56 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L1.97 8.53a.75.75 0 0 1 0-1.06Zm5.56 0a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 1 1-1.06-1.06L13.5 8l-2.72-2.72a.75.75 0 0 1 0-1.06Z"/></svg>`),
4044
 		"git-pull-request": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
@@ -49,6 +53,8 @@ func BuiltinOcticons() OcticonResolver {
4953
 			`><path d="M0 1.75A.75.75 0 0 1 .75 1h4.5C6.768 1 8 2.232 8 3.75A2.75 2.75 0 0 1 10.75 1h4.5a.75.75 0 0 1 .75.75v11.5a.75.75 0 0 1-.75.75h-4.5A1.25 1.25 0 0 0 9.5 15.25a.75.75 0 0 1-1.5 0A1.25 1.25 0 0 0 6.75 14H.75A.75.75 0 0 1 0 13.25ZM1.5 2.5v10h5.25c.63 0 1.21.23 1.75.61V3.75A1.25 1.25 0 0 0 7.25 2.5Zm7 10.61c.54-.38 1.12-.61 1.75-.61h4.25v-10h-3.75A1.25 1.25 0 0 0 9.5 3.75v9.36Z"/></svg>`),
5054
 		"law": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
5155
 			`><path d="M8.75.75a.75.75 0 0 0-1.5 0V2h-4.5a.75.75 0 0 0 0 1.5h.48L.34 9.13a.75.75 0 0 0-.09.36C.25 11.43 1.82 13 3.75 13s3.5-1.57 3.5-3.51a.75.75 0 0 0-.09-.36L4.27 3.5h2.98v10H5.75a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-10h2.98L8.84 9.13a.75.75 0 0 0-.09.36c0 1.94 1.57 3.51 3.5 3.51s3.5-1.57 3.5-3.51a.75.75 0 0 0-.09-.36L12.77 3.5h.48a.75.75 0 0 0 0-1.5h-4.5ZM3.75 11.5a2 2 0 0 1-1.88-1.31h3.76a2 2 0 0 1-1.88 1.31Zm8.5 0a2 2 0 0 1-1.88-1.31h3.76a2 2 0 0 1-1.88 1.31ZM2.2 8.69l1.55-3.02 1.55 3.02Zm8.5 0 1.55-3.02 1.55 3.02Z"/></svg>`),
56
+		"shield-check": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
57
+			`><path d="m8.54.637 5.5 1.785A1.75 1.75 0 0 1 15.25 4.086V7.5c0 4.126-2.51 7.136-6.266 8.554a1.705 1.705 0 0 1-1.198 0C4.01 14.636 1.5 11.626 1.5 7.5V4.086c0-.758.489-1.43 1.21-1.664L8.21.637a.75.75 0 0 1 .33 0Zm-.27 1.502-5.096 1.654a.25.25 0 0 0-.174.238V7.5c0 3.45 2.053 5.994 5.31 7.222a.2.2 0 0 0 .14 0c3.247-1.226 5.3-3.771 5.3-7.222V4.031a.25.25 0 0 0-.174-.238Zm3.26 3.581a.75.75 0 0 1 0 1.06L7.78 10.53a.75.75 0 0 1-1.06 0L4.97 8.78a.75.75 0 0 1 1.06-1.06l1.22 1.22 3.22-3.22a.75.75 0 0 1 1.06 0Z"/></svg>`),
5258
 		"issue-opened": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
5359
 			`><path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-3.25a.75.75 0 0 1 .75.75v2.75a.75.75 0 0 1-1.5 0V5.5A.75.75 0 0 1 8 4.75ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/></svg>`),
5460
 		"issue-closed": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
@@ -75,6 +81,10 @@ func BuiltinOcticons() OcticonResolver {
7581
 			`><path d="M3.75 1A1.75 1.75 0 0 0 2 2.75v10.5C2 14.216 2.784 15 3.75 15h8.5A1.75 1.75 0 0 0 14 13.25V5.664c0-.464-.184-.909-.513-1.237L10.573 1.513A1.75 1.75 0 0 0 9.336 1Zm0 1.5h5.5v2.75c0 .414.336.75.75.75h2.5v7.25a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25V2.75a.25.25 0 0 1 .25-.25Zm7 .56 1.19 1.19h-1.19ZM5.25 8a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5H6A.75.75 0 0 1 5.25 8Zm0 3a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75Z"/></svg>`),
7682
 		"package": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
7783
 			`><path d="M7.66.23a.75.75 0 0 1 .68 0l6 3A.75.75 0 0 1 14.75 3.9v8.2a.75.75 0 0 1-.41.67l-6 3a.75.75 0 0 1-.68 0l-6-3a.75.75 0 0 1-.41-.67V3.9a.75.75 0 0 1 .41-.67Zm.34 1.51L3.67 3.9 8 6.06l4.33-2.16ZM2.75 5.1v6.54l4.5 2.25V7.36Zm6 8.79 4.5-2.25V5.1l-4.5 2.26Z"/></svg>`),
84
+		"location": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
85
+			`><path d="M8 0a5.5 5.5 0 0 1 5.5 5.5c0 4.05-4.69 8.75-5.22 9.27a.4.4 0 0 1-.56 0C7.19 14.25 2.5 9.55 2.5 5.5A5.5 5.5 0 0 1 8 0Zm0 1.5a4 4 0 0 0-4 4c0 2.59 2.64 5.83 4 7.33 1.36-1.5 4-4.74 4-7.33a4 4 0 0 0-4-4Zm0 6.25a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5Zm0-1.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"/></svg>`),
86
+		"link": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
87
+			`><path d="M7.775 3.275a.75.75 0 0 1 0 1.06L4.53 7.58a2.25 2.25 0 1 0 3.182 3.182l1.47-1.47a.75.75 0 0 1 1.06 1.061l-1.47 1.47A3.75 3.75 0 1 1 3.47 6.52l3.245-3.245a.75.75 0 0 1 1.06 0Zm.45 9.45a.75.75 0 0 1 0-1.06l3.245-3.245a2.25 2.25 0 1 0-3.182-3.182l-1.47 1.47a.75.75 0 0 1-1.06-1.061l1.47-1.47A3.75 3.75 0 1 1 12.53 9.48l-3.245 3.245a.75.75 0 0 1-1.06 0Z"/></svg>`),
7888
 		"dot-fill": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
7989
 			`><path d="M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"/></svg>`),
8090
 		"milestone": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
internal/web/static/css/shithub.cssmodified
@@ -1153,6 +1153,389 @@ code {
11531153
 .shithub-repo-list-meta { color: var(--fg-muted); font-size: 0.8rem; display: flex; gap: 1rem; flex-wrap: wrap; margin: 0.4rem 0 0; }
11541154
 .shithub-pill-archived { background: #ffd35a; color: #3b2300; }
11551155
 
1156
+/* Organization overview. Mirrors GitHub's org homepage density:
1157
+   identity header, underline nav, two-column content, and a right rail. */
1158
+.shithub-org-profile {
1159
+  max-width: 1280px;
1160
+  margin: 0 auto;
1161
+}
1162
+.shithub-org-hero {
1163
+  padding: 1.5rem 1rem 0;
1164
+}
1165
+.shithub-org-hero-inner {
1166
+  display: grid;
1167
+  grid-template-columns: 96px minmax(0, 1fr) auto;
1168
+  gap: 1.25rem;
1169
+  align-items: start;
1170
+}
1171
+.shithub-org-avatar {
1172
+  width: 96px;
1173
+  height: 96px;
1174
+  border-radius: 6px;
1175
+  border: 1px solid var(--border-default);
1176
+  background: var(--canvas-subtle);
1177
+}
1178
+.shithub-org-identity h1 {
1179
+  margin: 0;
1180
+  font-size: 1.5rem;
1181
+  line-height: 1.25;
1182
+}
1183
+.shithub-org-handle {
1184
+  margin: 0.15rem 0 0.65rem;
1185
+  color: var(--fg-muted);
1186
+  font-size: 1rem;
1187
+}
1188
+.shithub-org-bio {
1189
+  max-width: 760px;
1190
+  margin: 0 0 0.75rem;
1191
+  color: var(--fg-default);
1192
+}
1193
+.shithub-org-meta {
1194
+  display: flex;
1195
+  flex-wrap: wrap;
1196
+  gap: 0.55rem 1rem;
1197
+  list-style: none;
1198
+  padding: 0;
1199
+  margin: 0;
1200
+  color: var(--fg-muted);
1201
+  font-size: 0.875rem;
1202
+}
1203
+.shithub-org-meta li,
1204
+.shithub-org-meta a,
1205
+.shithub-org-repo-meta span,
1206
+.shithub-org-repo-meta time {
1207
+  display: inline-flex;
1208
+  align-items: center;
1209
+  gap: 0.35rem;
1210
+}
1211
+.shithub-org-meta svg {
1212
+  flex: 0 0 auto;
1213
+}
1214
+.shithub-org-hero-actions {
1215
+  display: flex;
1216
+  gap: 0.5rem;
1217
+}
1218
+.shithub-org-nav {
1219
+  display: flex;
1220
+  gap: 0.15rem;
1221
+  padding: 1rem 1rem 0;
1222
+  margin-top: 1.25rem;
1223
+  overflow-x: auto;
1224
+  border-bottom: 1px solid var(--border-default);
1225
+}
1226
+.shithub-org-nav-item {
1227
+  display: inline-flex;
1228
+  align-items: center;
1229
+  gap: 0.4rem;
1230
+  flex: 0 0 auto;
1231
+  padding: 0.65rem 0.75rem;
1232
+  color: var(--fg-default);
1233
+  border-bottom: 2px solid transparent;
1234
+  font-size: 0.875rem;
1235
+  white-space: nowrap;
1236
+}
1237
+.shithub-org-nav-item:hover {
1238
+  background: var(--canvas-subtle);
1239
+  border-radius: 6px 6px 0 0;
1240
+  text-decoration: none;
1241
+}
1242
+.shithub-org-nav-item.is-active {
1243
+  border-bottom-color: #fd8c73;
1244
+  font-weight: 600;
1245
+}
1246
+.shithub-org-layout {
1247
+  display: grid;
1248
+  grid-template-columns: minmax(0, 2fr) minmax(260px, 0.72fr);
1249
+  gap: 2rem;
1250
+  padding: 1.5rem 1rem 2rem;
1251
+}
1252
+.shithub-org-main {
1253
+  min-width: 0;
1254
+}
1255
+.shithub-org-section-head {
1256
+  display: flex;
1257
+  align-items: center;
1258
+  justify-content: space-between;
1259
+  gap: 1rem;
1260
+  margin-bottom: 0.75rem;
1261
+}
1262
+.shithub-org-section-head h2,
1263
+.shithub-org-repo-head h2,
1264
+.shithub-org-sidebox h2 {
1265
+  margin: 0;
1266
+  font-size: 1rem;
1267
+  font-weight: 600;
1268
+}
1269
+.shithub-org-pinned-grid {
1270
+  display: grid;
1271
+  grid-template-columns: repeat(2, minmax(0, 1fr));
1272
+  gap: 0.75rem;
1273
+  list-style: none;
1274
+  padding: 0;
1275
+  margin: 0 0 1.5rem;
1276
+}
1277
+.shithub-org-pin-card {
1278
+  display: flex;
1279
+  min-height: 116px;
1280
+  flex-direction: column;
1281
+  justify-content: space-between;
1282
+  padding: 1rem;
1283
+  border: 1px solid var(--border-default);
1284
+  border-radius: 6px;
1285
+  background: var(--canvas-default);
1286
+}
1287
+.shithub-org-pin-title {
1288
+  display: flex;
1289
+  align-items: center;
1290
+  gap: 0.45rem;
1291
+  min-width: 0;
1292
+  font-weight: 600;
1293
+}
1294
+.shithub-org-pin-title a {
1295
+  overflow-wrap: anywhere;
1296
+}
1297
+.shithub-org-pin-icon {
1298
+  display: inline-flex;
1299
+  color: var(--fg-muted);
1300
+  flex: 0 0 auto;
1301
+}
1302
+.shithub-org-pin-card p {
1303
+  margin: 0.65rem 0;
1304
+  color: var(--fg-muted);
1305
+  font-size: 0.875rem;
1306
+}
1307
+.shithub-org-repo-head {
1308
+  display: grid;
1309
+  grid-template-columns: minmax(160px, 1fr) minmax(200px, 1.2fr) auto;
1310
+  gap: 0.75rem;
1311
+  align-items: center;
1312
+  margin-bottom: 0.75rem;
1313
+}
1314
+.shithub-org-repo-head h2 {
1315
+  display: inline-flex;
1316
+  align-items: center;
1317
+  gap: 0.4rem;
1318
+}
1319
+.shithub-org-repo-search input {
1320
+  width: 100%;
1321
+  min-height: 34px;
1322
+  padding: 0.35rem 0.75rem;
1323
+  border: 1px solid var(--border-default);
1324
+  border-radius: 6px;
1325
+  background: var(--canvas-default);
1326
+  color: var(--fg-default);
1327
+}
1328
+.shithub-org-repo-actions {
1329
+  display: flex;
1330
+  align-items: center;
1331
+  gap: 0.5rem;
1332
+  justify-content: flex-end;
1333
+  flex-wrap: wrap;
1334
+}
1335
+.shithub-filter-menu {
1336
+  position: relative;
1337
+}
1338
+.shithub-filter-menu summary {
1339
+  display: inline-flex;
1340
+  align-items: center;
1341
+  gap: 0.3rem;
1342
+  min-height: 34px;
1343
+  padding: 0.35rem 0.75rem;
1344
+  border: 1px solid var(--border-default);
1345
+  border-radius: 6px;
1346
+  background: var(--canvas-subtle);
1347
+  color: var(--fg-default);
1348
+  font-size: 0.875rem;
1349
+  font-weight: 600;
1350
+  cursor: pointer;
1351
+}
1352
+.shithub-filter-menu summary::-webkit-details-marker {
1353
+  display: none;
1354
+}
1355
+.shithub-filter-menu[open] > div {
1356
+  position: absolute;
1357
+  right: 0;
1358
+  z-index: 20;
1359
+  min-width: 160px;
1360
+  margin-top: 0.35rem;
1361
+  padding: 0.35rem 0;
1362
+  border: 1px solid var(--border-default);
1363
+  border-radius: 6px;
1364
+  background: var(--canvas-default);
1365
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
1366
+}
1367
+.shithub-filter-menu a {
1368
+  display: block;
1369
+  padding: 0.45rem 0.75rem;
1370
+  color: var(--fg-default);
1371
+  font-size: 0.875rem;
1372
+}
1373
+.shithub-filter-menu a:hover {
1374
+  background: var(--canvas-subtle);
1375
+  text-decoration: none;
1376
+}
1377
+.shithub-org-repo-list {
1378
+  list-style: none;
1379
+  padding: 0;
1380
+  margin: 0;
1381
+  border: 1px solid var(--border-default);
1382
+  border-radius: 6px;
1383
+  overflow: hidden;
1384
+  background: var(--canvas-default);
1385
+}
1386
+.shithub-org-repo-row {
1387
+  display: grid;
1388
+  grid-template-columns: minmax(0, 1fr) 116px;
1389
+  gap: 1rem;
1390
+  align-items: center;
1391
+  padding: 1rem;
1392
+  border-top: 1px solid var(--border-default);
1393
+}
1394
+.shithub-org-repo-row:first-child {
1395
+  border-top: 0;
1396
+}
1397
+.shithub-org-repo-row h3 {
1398
+  display: flex;
1399
+  align-items: center;
1400
+  gap: 0.45rem;
1401
+  flex-wrap: wrap;
1402
+  margin: 0;
1403
+  font-size: 1rem;
1404
+}
1405
+.shithub-org-repo-row p {
1406
+  margin: 0.35rem 0 0;
1407
+  color: var(--fg-muted);
1408
+  font-size: 0.875rem;
1409
+}
1410
+.shithub-org-row-topics {
1411
+  display: flex;
1412
+  flex-wrap: wrap;
1413
+  gap: 0.35rem;
1414
+  margin-top: 0.6rem;
1415
+}
1416
+.shithub-org-repo-meta {
1417
+  display: flex;
1418
+  flex-wrap: wrap;
1419
+  gap: 0.4rem 0.85rem;
1420
+  margin-top: 0.7rem;
1421
+  color: var(--fg-muted);
1422
+  font-size: 0.8rem;
1423
+}
1424
+.shithub-org-repo-meta svg {
1425
+  flex: 0 0 auto;
1426
+}
1427
+.shithub-org-repo-spark {
1428
+  justify-self: end;
1429
+  width: 112px;
1430
+  height: 28px;
1431
+  background:
1432
+    linear-gradient(135deg, transparent 0 66%, color-mix(in srgb, var(--success-fg) 75%, transparent) 67% 69%, transparent 70%),
1433
+    linear-gradient(170deg, transparent 0 48%, color-mix(in srgb, var(--success-fg) 65%, transparent) 49% 51%, transparent 52%);
1434
+  opacity: 0.85;
1435
+}
1436
+.shithub-org-sidebar {
1437
+  min-width: 0;
1438
+}
1439
+.shithub-org-sidebox {
1440
+  padding: 1rem 0;
1441
+  border-top: 1px solid var(--border-default);
1442
+}
1443
+.shithub-org-sidebox:first-child {
1444
+  padding-top: 0;
1445
+  border-top: 0;
1446
+}
1447
+.shithub-org-sidebox p {
1448
+  margin: 0.5rem 0 0;
1449
+  color: var(--fg-muted);
1450
+  font-size: 0.875rem;
1451
+}
1452
+.shithub-org-viewas {
1453
+  width: 100%;
1454
+  justify-content: center;
1455
+}
1456
+.shithub-org-people-strip {
1457
+  display: flex;
1458
+  flex-wrap: wrap;
1459
+  gap: 0.35rem;
1460
+  margin-top: 0.75rem;
1461
+}
1462
+.shithub-org-people-strip img {
1463
+  display: block;
1464
+  width: 32px;
1465
+  height: 32px;
1466
+  border-radius: 50%;
1467
+  border: 1px solid var(--border-muted);
1468
+}
1469
+.shithub-org-language-list {
1470
+  list-style: none;
1471
+  padding: 0;
1472
+  margin: 0.75rem 0 0;
1473
+}
1474
+.shithub-org-language-list li {
1475
+  display: flex;
1476
+  justify-content: space-between;
1477
+  gap: 1rem;
1478
+  margin: 0.4rem 0;
1479
+  color: var(--fg-muted);
1480
+  font-size: 0.875rem;
1481
+}
1482
+.shithub-org-language-list span {
1483
+  display: inline-flex;
1484
+  align-items: center;
1485
+  gap: 0.35rem;
1486
+}
1487
+.shithub-org-topic-list {
1488
+  display: flex;
1489
+  flex-wrap: wrap;
1490
+  gap: 0.4rem;
1491
+  margin-top: 0.75rem;
1492
+}
1493
+.shithub-org-empty {
1494
+  padding: 2rem;
1495
+  text-align: center;
1496
+  border: 1px dashed var(--border-default);
1497
+  border-radius: 6px;
1498
+  color: var(--fg-muted);
1499
+}
1500
+.shithub-org-empty h3 {
1501
+  margin: 0 0 0.75rem;
1502
+  color: var(--fg-default);
1503
+  font-size: 1rem;
1504
+}
1505
+@media (max-width: 960px) {
1506
+  .shithub-org-hero-inner,
1507
+  .shithub-org-layout,
1508
+  .shithub-org-repo-head {
1509
+    grid-template-columns: 1fr;
1510
+  }
1511
+  .shithub-org-avatar {
1512
+    width: 80px;
1513
+    height: 80px;
1514
+  }
1515
+  .shithub-org-hero-actions,
1516
+  .shithub-org-repo-actions {
1517
+    justify-content: flex-start;
1518
+  }
1519
+  .shithub-org-repo-row {
1520
+    grid-template-columns: 1fr;
1521
+  }
1522
+  .shithub-org-repo-spark {
1523
+    display: none;
1524
+  }
1525
+}
1526
+@media (max-width: 640px) {
1527
+  .shithub-org-pinned-grid {
1528
+    grid-template-columns: 1fr;
1529
+  }
1530
+  .shithub-org-nav {
1531
+    padding-inline: 0.5rem;
1532
+  }
1533
+  .shithub-org-layout,
1534
+  .shithub-org-hero {
1535
+    padding-inline: 0.75rem;
1536
+  }
1537
+}
1538
+
11561539
 .shithub-repo-header { margin-bottom: 1.25rem; }
11571540
 .shithub-repo-header-inner {
11581541
   display: flex;
internal/web/templates/orgs/profile.htmlmodified
@@ -1,33 +1,176 @@
11
 {{ define "page" -}}
22
 <section class="shithub-org-profile">
3
-  <header class="shithub-org-profile-head">
4
-    <h1>{{ .Org.DisplayName }}</h1>
5
-    <p class="shithub-meta">@{{ .Org.Slug }}</p>
6
-    {{ if .Org.Description }}<p>{{ .Org.Description }}</p>{{ end }}
7
-    <nav class="shithub-org-tabs">
8
-      <a href="/{{ .Org.Slug }}/people">People ({{ .MemberCount }})</a>
9
-      <a href="/{{ .Org.Slug }}/teams">Teams</a>
10
-      {{ if .IsOwner }}<a href="/{{ .Org.Slug }}/settings/profile">Settings</a>{{ end }}
11
-    </nav>
3
+  <header class="shithub-org-hero">
4
+    <div class="shithub-org-hero-inner">
5
+      <img class="shithub-org-avatar" src="{{ .AvatarURL }}" alt="" width="96" height="96">
6
+      <div class="shithub-org-identity">
7
+        <h1>{{ if .Org.DisplayName }}{{ .Org.DisplayName }}{{ else }}{{ .Org.Slug }}{{ end }}</h1>
8
+        <p class="shithub-org-handle">@{{ .Org.Slug }}</p>
9
+        {{ if .Org.Description }}<p class="shithub-org-bio">{{ .Org.Description }}</p>{{ end }}
10
+        <ul class="shithub-org-meta" aria-label="Organization metadata">
11
+          {{ if .Org.Location }}<li>{{ octicon "location" }} <span>{{ .Org.Location }}</span></li>{{ end }}
12
+          {{ if .WebsiteSafe }}<li>{{ octicon "link" }} <a href="{{ .WebsiteSafe }}" rel="nofollow noopener">{{ .Org.Website }}</a></li>{{ end }}
13
+        </ul>
14
+      </div>
15
+      <div class="shithub-org-hero-actions">
16
+        {{ if .IsOwner }}<a href="/{{ .Org.Slug }}/settings/profile" class="shithub-button">Settings</a>{{ else }}<button type="button" class="shithub-button">Follow</button>{{ end }}
17
+      </div>
18
+    </div>
1219
   </header>
20
+
21
+  <nav class="shithub-org-nav" aria-label="Organization">
22
+    <a href="/{{ .Org.Slug }}" class="shithub-org-nav-item is-active">{{ octicon "home" }} Overview</a>
23
+    <a href="#org-repositories" class="shithub-org-nav-item">{{ octicon "repo" }} Repositories <span class="shithub-tab-count">{{ .RepoCount }}</span></a>
24
+    <a href="/{{ .Org.Slug }}/projects" class="shithub-org-nav-item">{{ octicon "table" }} Projects</a>
25
+    <a href="/{{ .Org.Slug }}/packages" class="shithub-org-nav-item">{{ octicon "package" }} Packages</a>
26
+    <a href="/{{ .Org.Slug }}/teams" class="shithub-org-nav-item">{{ octicon "people" }} Teams</a>
27
+    <a href="/{{ .Org.Slug }}/people" class="shithub-org-nav-item">{{ octicon "person" }} People <span class="shithub-tab-count">{{ .MemberCount }}</span></a>
28
+    <a href="/{{ .Org.Slug }}/security" class="shithub-org-nav-item">{{ octicon "shield-check" }} Security and quality</a>
29
+    <a href="/{{ .Org.Slug }}/insights" class="shithub-org-nav-item">{{ octicon "pulse" }} Insights</a>
30
+    {{ if .IsOwner }}<a href="/{{ .Org.Slug }}/settings/profile" class="shithub-org-nav-item">{{ octicon "gear" }} Settings</a>{{ end }}
31
+  </nav>
32
+
1333
   {{ if .Org.SuspendedAt.Valid }}
14
-  <p class="shithub-flash shithub-flash-error">This organization is suspended. Pushes are blocked; reads continue.</p>
34
+  <p class="shithub-flash shithub-flash-error" role="alert">This organization is suspended. Pushes are blocked; reads continue.</p>
1535
   {{ end }}
16
-  <section class="shithub-org-repos">
17
-    <h2>Repositories</h2>
18
-    {{ if .Repos }}
19
-      <ul class="shithub-search-list">
20
-      {{ range .Repos }}
21
-        <li>
22
-          <a href="/{{ $.Org.Slug }}/{{ .Name }}"><strong>{{ $.Org.Slug }}/{{ .Name }}</strong></a>
23
-          {{ if eq (printf "%s" .Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ end }}
24
-          {{ if .Description }}<p class="shithub-meta">{{ .Description }}</p>{{ end }}
25
-        </li>
36
+
37
+  <div class="shithub-org-layout">
38
+    <main class="shithub-org-main">
39
+      {{ if .PinnedRepos }}
40
+      <section class="shithub-org-pinned" aria-labelledby="org-pinned-heading">
41
+        <div class="shithub-org-section-head">
42
+          <h2 id="org-pinned-heading">Pinned</h2>
43
+          {{ if .IsOwner }}<a href="/{{ .Org.Slug }}/settings/profile">Customize pins</a>{{ end }}
44
+        </div>
45
+        <ol class="shithub-org-pinned-grid">
46
+        {{ range .PinnedRepos }}
47
+          <li class="shithub-org-pin-card">
48
+            <div class="shithub-org-pin-title">
49
+              <span class="shithub-org-pin-icon">{{ octicon "repo" }}</span>
50
+              <a href="/{{ $.Org.Slug }}/{{ .Name }}">{{ .Name }}</a>
51
+              {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }}
52
+            </div>
53
+            {{ if .Description }}<p>{{ .Description }}</p>{{ else }}<p class="shithub-muted">No description provided.</p>{{ end }}
54
+            <div class="shithub-org-repo-meta">
55
+              {{ if .PrimaryLanguage }}<span><span class="shithub-language-dot" style="background-color: {{ .PrimaryLanguageColor }};"></span>{{ .PrimaryLanguage }}</span>{{ end }}
56
+              <span>{{ octicon "star" }} {{ .StarCount }}</span>
57
+              <span>{{ octicon "repo-forked" }} {{ .ForkCount }}</span>
58
+            </div>
59
+          </li>
60
+        {{ end }}
61
+        </ol>
62
+      </section>
2663
       {{ end }}
27
-      </ul>
28
-    {{ else }}
29
-      <p class="shithub-empty">No repositories yet.</p>
30
-    {{ end }}
31
-  </section>
64
+
65
+      <section class="shithub-org-repos" id="org-repositories" aria-labelledby="org-repositories-heading">
66
+        <div class="shithub-org-repo-head">
67
+          <h2 id="org-repositories-heading">{{ octicon "repo" }} Repositories</h2>
68
+          <form action="/search" method="get" role="search" class="shithub-org-repo-search">
69
+            <input type="search" name="q" placeholder="Find a repository..." aria-label="Find a repository">
70
+            <input type="hidden" name="type" value="repos">
71
+          </form>
72
+          <div class="shithub-org-repo-actions">
73
+            <details class="shithub-filter-menu">
74
+              <summary>Type {{ octicon "triangle-down" }}</summary>
75
+              <div><a href="#org-repositories">All</a><a href="#org-repositories">Public</a><a href="#org-repositories">Private</a></div>
76
+            </details>
77
+            <details class="shithub-filter-menu">
78
+              <summary>Language {{ octicon "triangle-down" }}</summary>
79
+              <div><a href="#org-repositories">All</a>{{ range .TopLanguages }}<a href="#org-repositories">{{ .Name }}</a>{{ end }}</div>
80
+            </details>
81
+            <details class="shithub-filter-menu">
82
+              <summary>Sort {{ octicon "triangle-down" }}</summary>
83
+              <div><a href="#org-repositories">Last updated</a><a href="#org-repositories">Name</a><a href="#org-repositories">Stars</a></div>
84
+            </details>
85
+            {{ if .CanCreateRepo }}<a href="/new" class="shithub-button shithub-button-primary">{{ octicon "repo" }} New</a>{{ end }}
86
+          </div>
87
+        </div>
88
+
89
+        {{ if .Repos }}
90
+        <ul class="shithub-org-repo-list">
91
+        {{ range .Repos }}
92
+          <li class="shithub-org-repo-row">
93
+            <div class="shithub-org-repo-row-main">
94
+              <h3>
95
+                <a href="/{{ $.Org.Slug }}/{{ .Name }}">{{ .Name }}</a>
96
+                {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }}
97
+                {{ if .IsArchived }}<span class="shithub-pill shithub-pill-archived">Archived</span>{{ end }}
98
+              </h3>
99
+              {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
100
+              {{ if .Topics }}
101
+              <div class="shithub-org-row-topics">
102
+                {{ range .Topics }}<a href="/search?q=topic:{{ . }}&amp;type=repos" class="shithub-topic">{{ . }}</a>{{ end }}
103
+              </div>
104
+              {{ end }}
105
+              <div class="shithub-org-repo-meta">
106
+                {{ if .PrimaryLanguage }}<span><span class="shithub-language-dot" style="background-color: {{ .PrimaryLanguageColor }};"></span>{{ .PrimaryLanguage }}</span>{{ end }}
107
+                {{ if .LicenseKey }}<span>{{ octicon "law" }} {{ .LicenseKey }}</span>{{ end }}
108
+                <span>{{ octicon "star" }} {{ .StarCount }}</span>
109
+                <span>{{ octicon "repo-forked" }} {{ .ForkCount }}</span>
110
+                <time datetime="{{ .UpdatedAt.Format "2006-01-02T15:04:05Z" }}">Updated {{ relativeTime .UpdatedAt }}</time>
111
+              </div>
112
+            </div>
113
+            <span class="shithub-org-repo-spark" aria-hidden="true"></span>
114
+          </li>
115
+        {{ end }}
116
+        </ul>
117
+        {{ else }}
118
+        <div class="shithub-org-empty">
119
+          <h3>No repositories yet.</h3>
120
+          {{ if .CanCreateRepo }}<a href="/new" class="shithub-button shithub-button-primary">Create a repository</a>{{ end }}
121
+        </div>
122
+        {{ end }}
123
+      </section>
124
+    </main>
125
+
126
+    <aside class="shithub-org-sidebar" aria-label="Organization sidebar">
127
+      <section class="shithub-org-sidebox">
128
+        <button type="button" class="shithub-button shithub-org-viewas">{{ octicon "eye" }} View as: {{ .ViewAs }}</button>
129
+        <p>You are viewing the public profile, README, and visible repositories for this organization.</p>
130
+      </section>
131
+
132
+      <section class="shithub-org-sidebox">
133
+        <h2>Discussions</h2>
134
+        <p>Set up discussions to engage with your community.</p>
135
+        {{ if .IsOwner }}<a href="/{{ .Org.Slug }}/settings/profile">Turn on discussions</a>{{ end }}
136
+      </section>
137
+
138
+      <section class="shithub-org-sidebox">
139
+        <h2>People</h2>
140
+        {{ if .People }}
141
+        <div class="shithub-org-people-strip">
142
+          {{ range .People }}<a href="/{{ .Username }}" title="{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}"><img src="{{ .AvatarURL }}" alt="" width="32" height="32"></a>{{ end }}
143
+        </div>
144
+        {{ else }}
145
+        <p class="shithub-muted">This organization has no public members.</p>
146
+        {{ end }}
147
+        <p><strong>{{ .MemberCount }}</strong> member{{ if ne .MemberCount 1 }}s{{ end }}</p>
148
+      </section>
149
+
150
+      <section class="shithub-org-sidebox">
151
+        <h2>Top languages</h2>
152
+        {{ if .TopLanguages }}
153
+        <ul class="shithub-org-language-list">
154
+          {{ range .TopLanguages }}
155
+          <li><span><span class="shithub-language-dot" style="background-color: {{ .Color }};"></span>{{ .Name }}</span><span>{{ .Percent }}%</span></li>
156
+          {{ end }}
157
+        </ul>
158
+        {{ else }}
159
+        <p class="shithub-muted">No languages detected.</p>
160
+        {{ end }}
161
+      </section>
162
+
163
+      <section class="shithub-org-sidebox">
164
+        <h2>Most used topics</h2>
165
+        {{ if .TopTopics }}
166
+        <div class="shithub-org-topic-list">
167
+          {{ range .TopTopics }}<a href="/search?q=topic:{{ .Name }}&amp;type=repos" class="shithub-topic">{{ .Name }}</a>{{ end }}
168
+        </div>
169
+        {{ else }}
170
+        <p class="shithub-muted">No topics yet.</p>
171
+        {{ end }}
172
+      </section>
173
+    </aside>
174
+  </div>
32175
 </section>
33176
 {{- end }}