Align org overview homepage
- SHA
d882795e7ebb52d85de562f301c527d0a8167080- Parents
-
1263f26 - Tree
3ae79ae
d882795
d882795e7ebb52d85de562f301c527d0a81670801263f26
3ae79ae| Status | File | + | - |
|---|---|---|---|
| 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 ( | ||
| 29 | 29 | "github.com/tenseleyFlow/shithub/internal/avatars" |
| 30 | 30 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 31 | 31 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 32 | - orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" | |
| 33 | - reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" | |
| 34 | 32 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 35 | 33 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 36 | 34 | "github.com/tenseleyFlow/shithub/internal/web/render" |
@@ -290,81 +288,3 @@ func safeWebsite(s string) template.URL { | ||
| 290 | 288 | // ensure context import is used by static analysis even if a future |
| 291 | 289 | // refactor removes its only inline use. |
| 292 | 290 | 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 { | ||
| 38 | 38 | "hello.html": {Data: []byte(`{{ define "page" }}home{{ end }}`)}, |
| 39 | 39 | "profile/view.html": {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}}{{ end }}`)}, |
| 40 | 40 | "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 }}`)}, | |
| 41 | 42 | "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)}, |
| 42 | 43 | "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)}, |
| 43 | 44 | } |
@@ -90,6 +91,49 @@ func (e *profileEnv) insertUser(t *testing.T, username, display, bio string) use | ||
| 90 | 91 | return user |
| 91 | 92 | } |
| 92 | 93 | |
| 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 | + | |
| 93 | 137 | func (e *profileEnv) insertRedirect(t *testing.T, oldname string, userID int64) { |
| 94 | 138 | t.Helper() |
| 95 | 139 | if _, err := e.pool.Exec(context.Background(), |
@@ -191,6 +235,44 @@ func TestProfile_UsernameRedirect(t *testing.T) { | ||
| 191 | 235 | } |
| 192 | 236 | } |
| 193 | 237 | |
| 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 | + | |
| 194 | 276 | func TestProfile_SuspendedRendersUnavailable(t *testing.T) { |
| 195 | 277 | t.Parallel() |
| 196 | 278 | env := setupProfileEnv(t) |
internal/web/render/octicons.gomodified@@ -35,6 +35,10 @@ func BuiltinOcticons() OcticonResolver { | ||
| 35 | 35 | `><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>`), |
| 36 | 36 | "repo": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 37 | 37 | `><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>`), | |
| 38 | 42 | "code": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 39 | 43 | `><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>`), |
| 40 | 44 | "git-pull-request": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
@@ -49,6 +53,8 @@ func BuiltinOcticons() OcticonResolver { | ||
| 49 | 53 | `><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>`), |
| 50 | 54 | "law": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 51 | 55 | `><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>`), | |
| 52 | 58 | "issue-opened": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 53 | 59 | `><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>`), |
| 54 | 60 | "issue-closed": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
@@ -75,6 +81,10 @@ func BuiltinOcticons() OcticonResolver { | ||
| 75 | 81 | `><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>`), |
| 76 | 82 | "package": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 77 | 83 | `><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>`), | |
| 78 | 88 | "dot-fill": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 79 | 89 | `><path d="M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"/></svg>`), |
| 80 | 90 | "milestone": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
internal/web/static/css/shithub.cssmodified@@ -1153,6 +1153,389 @@ code { | ||
| 1153 | 1153 | .shithub-repo-list-meta { color: var(--fg-muted); font-size: 0.8rem; display: flex; gap: 1rem; flex-wrap: wrap; margin: 0.4rem 0 0; } |
| 1154 | 1154 | .shithub-pill-archived { background: #ffd35a; color: #3b2300; } |
| 1155 | 1155 | |
| 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 | + | |
| 1156 | 1539 | .shithub-repo-header { margin-bottom: 1.25rem; } |
| 1157 | 1540 | .shithub-repo-header-inner { |
| 1158 | 1541 | display: flex; |
internal/web/templates/orgs/profile.htmlmodified@@ -1,33 +1,176 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | 2 | <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> | |
| 12 | 19 | </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 | + | |
| 13 | 33 | {{ 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> | |
| 15 | 35 | {{ 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> | |
| 26 | 63 | {{ 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:{{ . }}&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 }}&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> | |
| 32 | 175 | </section> |
| 33 | 176 | {{- end }} |