Align org people page with GitHub
- SHA
ebeab2e6390040cfbecd08a5bdc8a935b841c56c- Parents
-
047f0ac - Tree
ba1c702
ebeab2e
ebeab2e6390040cfbecd08a5bdc8a935b841c56c047f0ac
ba1c702| Status | File | + | - |
|---|---|---|---|
| M |
docs/internal/orgs.md
|
8 | 0 |
| M |
internal/web/handlers/orgs/orgs.go
|
31 | 6 |
| A |
internal/web/handlers/orgs/people_test.go
|
176 | 0 |
| M |
internal/web/static/css/shithub.css
|
324 | 1 |
| M |
internal/web/templates/orgs/people.html
|
122 | 63 |
docs/internal/orgs.mdmodified@@ -60,6 +60,14 @@ honors that hint after matching it against the viewer's allowed owner | ||
| 60 | 60 | picker entries, so unauthorized org hints fall back to the viewer's |
| 61 | 61 | personal namespace. |
| 62 | 62 | |
| 63 | +`GET /{org}/people` uses the same GitHub-style org pagehead and | |
| 64 | +underline navigation, then renders the People surface as a permissions | |
| 65 | +layout: a left-side "Organization permissions" menu, a member search | |
| 66 | +toolbar, bordered member rows with avatars, and an owner-only Invite | |
| 67 | +member action. The `query` URL parameter filters members by username, | |
| 68 | +display name, or role without changing the membership management | |
| 69 | +routes. | |
| 70 | + | |
| 63 | 71 | Organization owners see a "Customize pins" modal on the overview. The |
| 64 | 72 | picker mirrors GitHub's public-profile rule: it offers only public |
| 65 | 73 | org-owned repos, has a live text filter, caps selections at six, and |
internal/web/handlers/orgs/orgs.gomodified@@ -22,6 +22,7 @@ import ( | ||
| 22 | 22 | "errors" |
| 23 | 23 | "log/slog" |
| 24 | 24 | "net/http" |
| 25 | + "net/url" | |
| 25 | 26 | "strconv" |
| 26 | 27 | "strings" |
| 27 | 28 | |
@@ -190,6 +191,8 @@ func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) { | ||
| 190 | 191 | h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 191 | 192 | return |
| 192 | 193 | } |
| 194 | + query := strings.TrimSpace(r.URL.Query().Get("query")) | |
| 195 | + filteredMembers := filterOrgMembers(members, query) | |
| 193 | 196 | var pending []orgsdb.ListPendingInvitationsForOrgRow |
| 194 | 197 | isOwner := false |
| 195 | 198 | if !viewer.IsAnonymous() { |
@@ -199,15 +202,37 @@ func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) { | ||
| 199 | 202 | } |
| 200 | 203 | } |
| 201 | 204 | _ = h.d.Render.RenderPage(w, r, "orgs/people", map[string]any{ |
| 202 | - "Title": org.Slug + " · people", | |
| 203 | - "CSRFToken": middleware.CSRFTokenForRequest(r), | |
| 204 | - "Org": org, | |
| 205 | - "Members": members, | |
| 206 | - "Pending": pending, | |
| 207 | - "IsOwner": isOwner, | |
| 205 | + "Title": org.Slug + " · people", | |
| 206 | + "CSRFToken": middleware.CSRFTokenForRequest(r), | |
| 207 | + "Org": org, | |
| 208 | + "AvatarURL": "/avatars/" + url.PathEscape(org.Slug), | |
| 209 | + "Members": filteredMembers, | |
| 210 | + "MemberCount": len(members), | |
| 211 | + "Pending": pending, | |
| 212 | + "PendingCount": len(pending), | |
| 213 | + "Query": query, | |
| 214 | + "HasQuery": query != "", | |
| 215 | + "IsOwner": isOwner, | |
| 216 | + "CanManagePeople": isOwner, | |
| 208 | 217 | }) |
| 209 | 218 | } |
| 210 | 219 | |
| 220 | +func filterOrgMembers(members []orgsdb.ListOrgMembersRow, query string) []orgsdb.ListOrgMembersRow { | |
| 221 | + query = strings.ToLower(strings.TrimSpace(query)) | |
| 222 | + if query == "" { | |
| 223 | + return members | |
| 224 | + } | |
| 225 | + out := make([]orgsdb.ListOrgMembersRow, 0, len(members)) | |
| 226 | + for _, member := range members { | |
| 227 | + if strings.Contains(strings.ToLower(member.Username), query) || | |
| 228 | + strings.Contains(strings.ToLower(member.DisplayName), query) || | |
| 229 | + strings.Contains(strings.ToLower(string(member.Role)), query) { | |
| 230 | + out = append(out, member) | |
| 231 | + } | |
| 232 | + } | |
| 233 | + return out | |
| 234 | +} | |
| 235 | + | |
| 211 | 236 | func (h *Handlers) invite(w http.ResponseWriter, r *http.Request) { |
| 212 | 237 | org, ok := h.orgFromSlug(w, r) |
| 213 | 238 | if !ok { |
internal/web/handlers/orgs/people_test.goadded@@ -0,0 +1,176 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package orgs_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "io" | |
| 8 | + "log/slog" | |
| 9 | + "net/http" | |
| 10 | + "net/http/httptest" | |
| 11 | + "strconv" | |
| 12 | + "strings" | |
| 13 | + "testing" | |
| 14 | + | |
| 15 | + "github.com/go-chi/chi/v5" | |
| 16 | + "github.com/jackc/pgx/v5/pgxpool" | |
| 17 | + | |
| 18 | + "github.com/tenseleyFlow/shithub/internal/testing/dbtest" | |
| 19 | + "github.com/tenseleyFlow/shithub/internal/web" | |
| 20 | + orgsh "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs" | |
| 21 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" | |
| 22 | + "github.com/tenseleyFlow/shithub/internal/web/render" | |
| 23 | +) | |
| 24 | + | |
| 25 | +func TestOrgPeoplePageSearchAndOwnerChrome(t *testing.T) { | |
| 26 | + t.Parallel() | |
| 27 | + env := setupOrgPeopleEnv(t) | |
| 28 | + | |
| 29 | + ownerID := insertOrgPeopleUser(t, env.pool, "mfwolffe", "Matt Wolffe") | |
| 30 | + orgID := insertOrgPeopleOrg(t, env.pool, ownerID, "tenseleyFlow") | |
| 31 | + bobID := insertOrgPeopleUser(t, env.pool, "bob", "Bob Builder") | |
| 32 | + carolID := insertOrgPeopleUser(t, env.pool, "carol", "Carol Coder") | |
| 33 | + insertOrgPeopleMember(t, env.pool, orgID, bobID, "member") | |
| 34 | + insertOrgPeopleMember(t, env.pool, orgID, carolID, "member") | |
| 35 | + | |
| 36 | + body := env.get(t, "/tenseleyFlow/people?query=BUILDER", ownerID, "mfwolffe") | |
| 37 | + for _, want := range []string{ | |
| 38 | + `class="shithub-org-pagehead"`, | |
| 39 | + `Organization permissions`, | |
| 40 | + `Find a member...`, | |
| 41 | + `Invite member`, | |
| 42 | + `Bob Builder`, | |
| 43 | + `value="BUILDER"`, | |
| 44 | + `shithub-tab-count">3`, | |
| 45 | + } { | |
| 46 | + if !strings.Contains(body, want) { | |
| 47 | + t.Fatalf("owner search body missing %q\n%s", want, body) | |
| 48 | + } | |
| 49 | + } | |
| 50 | + if strings.Contains(body, "Carol Coder") { | |
| 51 | + t.Fatalf("filtered search should not include Carol Coder\n%s", body) | |
| 52 | + } | |
| 53 | + | |
| 54 | + body = env.get(t, "/tenseleyFlow/people", 0, "") | |
| 55 | + for _, want := range []string{`Bob Builder`, `Carol Coder`, `Matt Wolffe`} { | |
| 56 | + if !strings.Contains(body, want) { | |
| 57 | + t.Fatalf("anonymous member list missing %q\n%s", want, body) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + if strings.Contains(body, "Invite member") { | |
| 61 | + t.Fatalf("anonymous people page should not show owner-only invite control\n%s", body) | |
| 62 | + } | |
| 63 | + | |
| 64 | + body = env.get(t, "/tenseleyFlow/people?query=missing", ownerID, "mfwolffe") | |
| 65 | + if !strings.Contains(body, "No members matched your search.") { | |
| 66 | + t.Fatalf("expected search empty state\n%s", body) | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +type orgPeopleEnv struct { | |
| 71 | + srv *httptest.Server | |
| 72 | + pool *pgxpool.Pool | |
| 73 | +} | |
| 74 | + | |
| 75 | +func setupOrgPeopleEnv(t *testing.T) *orgPeopleEnv { | |
| 76 | + t.Helper() | |
| 77 | + pool := dbtest.NewTestDB(t) | |
| 78 | + rr, err := render.New(web.TemplatesFS(), render.Options{Octicons: render.BuiltinOcticons()}) | |
| 79 | + if err != nil { | |
| 80 | + t.Fatalf("render.New: %v", err) | |
| 81 | + } | |
| 82 | + h, err := orgsh.New(orgsh.Deps{ | |
| 83 | + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 84 | + Render: rr, | |
| 85 | + Pool: pool, | |
| 86 | + }) | |
| 87 | + if err != nil { | |
| 88 | + t.Fatalf("orgs.New: %v", err) | |
| 89 | + } | |
| 90 | + r := chi.NewRouter() | |
| 91 | + r.Use(func(next http.Handler) http.Handler { | |
| 92 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 93 | + rawID := r.Header.Get("X-Test-User-ID") | |
| 94 | + if rawID == "" { | |
| 95 | + next.ServeHTTP(w, r) | |
| 96 | + return | |
| 97 | + } | |
| 98 | + id, err := strconv.ParseInt(rawID, 10, 64) | |
| 99 | + if err != nil { | |
| 100 | + t.Fatalf("bad X-Test-User-ID: %v", err) | |
| 101 | + } | |
| 102 | + viewer := middleware.CurrentUser{ID: id, Username: r.Header.Get("X-Test-Username")} | |
| 103 | + next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer))) | |
| 104 | + }) | |
| 105 | + }) | |
| 106 | + h.MountOrgRoutes(r) | |
| 107 | + srv := httptest.NewServer(r) | |
| 108 | + t.Cleanup(srv.Close) | |
| 109 | + return &orgPeopleEnv{srv: srv, pool: pool} | |
| 110 | +} | |
| 111 | + | |
| 112 | +func (e *orgPeopleEnv) get(t *testing.T, path string, userID int64, username string) string { | |
| 113 | + t.Helper() | |
| 114 | + req, err := http.NewRequest(http.MethodGet, e.srv.URL+path, nil) | |
| 115 | + if err != nil { | |
| 116 | + t.Fatalf("NewRequest: %v", err) | |
| 117 | + } | |
| 118 | + if userID != 0 { | |
| 119 | + req.Header.Set("X-Test-User-ID", strconv.FormatInt(userID, 10)) | |
| 120 | + req.Header.Set("X-Test-Username", username) | |
| 121 | + } | |
| 122 | + resp, err := http.DefaultClient.Do(req) | |
| 123 | + if err != nil { | |
| 124 | + t.Fatalf("GET %s: %v", path, err) | |
| 125 | + } | |
| 126 | + body, _ := io.ReadAll(resp.Body) | |
| 127 | + _ = resp.Body.Close() | |
| 128 | + if resp.StatusCode != http.StatusOK { | |
| 129 | + t.Fatalf("GET %s status=%d body=%s", path, resp.StatusCode, body) | |
| 130 | + } | |
| 131 | + return string(body) | |
| 132 | +} | |
| 133 | + | |
| 134 | +const orgPeopleFixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" + | |
| 135 | + "AAAAAAAAAAAAAAAA$" + | |
| 136 | + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | |
| 137 | + | |
| 138 | +func insertOrgPeopleUser(t *testing.T, db *pgxpool.Pool, username, display string) int64 { | |
| 139 | + t.Helper() | |
| 140 | + var id int64 | |
| 141 | + if err := db.QueryRow(context.Background(), | |
| 142 | + `INSERT INTO users (username, display_name, password_hash) | |
| 143 | + VALUES ($1, $2, $3) | |
| 144 | + RETURNING id`, | |
| 145 | + username, display, orgPeopleFixtureHash, | |
| 146 | + ).Scan(&id); err != nil { | |
| 147 | + t.Fatalf("insert user %s: %v", username, err) | |
| 148 | + } | |
| 149 | + return id | |
| 150 | +} | |
| 151 | + | |
| 152 | +func insertOrgPeopleOrg(t *testing.T, db *pgxpool.Pool, ownerID int64, slug string) int64 { | |
| 153 | + t.Helper() | |
| 154 | + var orgID int64 | |
| 155 | + if err := db.QueryRow(context.Background(), | |
| 156 | + `INSERT INTO orgs (slug, display_name, created_by_user_id) | |
| 157 | + VALUES ($1, $2, $3) | |
| 158 | + RETURNING id`, | |
| 159 | + slug, slug, ownerID, | |
| 160 | + ).Scan(&orgID); err != nil { | |
| 161 | + t.Fatalf("insert org: %v", err) | |
| 162 | + } | |
| 163 | + insertOrgPeopleMember(t, db, orgID, ownerID, "owner") | |
| 164 | + return orgID | |
| 165 | +} | |
| 166 | + | |
| 167 | +func insertOrgPeopleMember(t *testing.T, db *pgxpool.Pool, orgID, userID int64, role string) { | |
| 168 | + t.Helper() | |
| 169 | + if _, err := db.Exec(context.Background(), | |
| 170 | + `INSERT INTO org_members (org_id, user_id, role) | |
| 171 | + VALUES ($1, $2, $3)`, | |
| 172 | + orgID, userID, role, | |
| 173 | + ); err != nil { | |
| 174 | + t.Fatalf("insert org member: %v", err) | |
| 175 | + } | |
| 176 | +} | |
internal/web/static/css/shithub.cssmodified@@ -2225,6 +2225,301 @@ code { | ||
| 2225 | 2225 | color: var(--fg-default); |
| 2226 | 2226 | font-size: 1rem; |
| 2227 | 2227 | } |
| 2228 | + | |
| 2229 | +/* Organization People page. Mirrors GitHub's compact org header, | |
| 2230 | + permissions sidebar, toolbar search, and bordered member rows. */ | |
| 2231 | +.shithub-org-people { | |
| 2232 | + max-width: none; | |
| 2233 | +} | |
| 2234 | +.shithub-org-pagehead { | |
| 2235 | + padding-top: 1rem; | |
| 2236 | + border-bottom: 1px solid var(--border-default); | |
| 2237 | +} | |
| 2238 | +.shithub-org-pagehead-inner { | |
| 2239 | + max-width: 1280px; | |
| 2240 | + margin: 0 auto; | |
| 2241 | + padding: 0 1rem; | |
| 2242 | +} | |
| 2243 | +.shithub-org-pagehead-title { | |
| 2244 | + display: inline-flex; | |
| 2245 | + align-items: center; | |
| 2246 | + gap: 0.6rem; | |
| 2247 | + color: var(--fg-default); | |
| 2248 | + font-size: 1.25rem; | |
| 2249 | + font-weight: 600; | |
| 2250 | + line-height: 1.25; | |
| 2251 | +} | |
| 2252 | +.shithub-org-pagehead-title:hover { | |
| 2253 | + text-decoration: none; | |
| 2254 | +} | |
| 2255 | +.shithub-org-pagehead-title img { | |
| 2256 | + display: block; | |
| 2257 | + width: 30px; | |
| 2258 | + height: 30px; | |
| 2259 | + border-radius: 6px; | |
| 2260 | + border: 1px solid var(--border-muted); | |
| 2261 | + background: var(--canvas-subtle); | |
| 2262 | +} | |
| 2263 | +.shithub-org-pagehead .shithub-org-nav { | |
| 2264 | + max-width: 1280px; | |
| 2265 | + margin: 0.9rem auto 0; | |
| 2266 | + padding: 0 1rem; | |
| 2267 | + border-bottom: 0; | |
| 2268 | +} | |
| 2269 | +.shithub-org-people-layout { | |
| 2270 | + display: grid; | |
| 2271 | + grid-template-columns: 250px minmax(0, 1fr); | |
| 2272 | + gap: 1.5rem; | |
| 2273 | + max-width: 1280px; | |
| 2274 | + margin: 0 auto; | |
| 2275 | + padding: 1.5rem 1rem 2rem; | |
| 2276 | +} | |
| 2277 | +.shithub-org-people-sidebar { | |
| 2278 | + min-width: 0; | |
| 2279 | +} | |
| 2280 | +.shithub-org-people-sidebar h1 { | |
| 2281 | + margin: 0 0 1rem; | |
| 2282 | + font-size: 1.5rem; | |
| 2283 | + line-height: 1.25; | |
| 2284 | +} | |
| 2285 | +.shithub-org-people-menu { | |
| 2286 | + overflow: hidden; | |
| 2287 | + border: 1px solid var(--border-default); | |
| 2288 | + border-radius: 6px; | |
| 2289 | + background: var(--canvas-default); | |
| 2290 | +} | |
| 2291 | +.shithub-org-people-menu h2 { | |
| 2292 | + display: flex; | |
| 2293 | + align-items: center; | |
| 2294 | + justify-content: space-between; | |
| 2295 | + margin: 0; | |
| 2296 | + padding: 0.65rem 0.85rem; | |
| 2297 | + border-bottom: 1px solid var(--border-default); | |
| 2298 | + background: var(--canvas-subtle); | |
| 2299 | + font-size: 0.875rem; | |
| 2300 | + font-weight: 600; | |
| 2301 | +} | |
| 2302 | +.shithub-org-people-menu a { | |
| 2303 | + display: flex; | |
| 2304 | + align-items: center; | |
| 2305 | + justify-content: space-between; | |
| 2306 | + gap: 1rem; | |
| 2307 | + padding: 0.55rem 0.85rem; | |
| 2308 | + border-bottom: 1px solid var(--border-muted); | |
| 2309 | + color: var(--fg-default); | |
| 2310 | + font-size: 0.875rem; | |
| 2311 | +} | |
| 2312 | +.shithub-org-people-menu a:last-child { | |
| 2313 | + border-bottom: 0; | |
| 2314 | +} | |
| 2315 | +.shithub-org-people-menu a:hover { | |
| 2316 | + background: var(--canvas-subtle); | |
| 2317 | + text-decoration: none; | |
| 2318 | +} | |
| 2319 | +.shithub-org-people-menu a.is-selected { | |
| 2320 | + color: var(--fg-default); | |
| 2321 | + font-weight: 600; | |
| 2322 | + background: var(--canvas-subtle); | |
| 2323 | + box-shadow: inset 2px 0 0 #fd8c73; | |
| 2324 | +} | |
| 2325 | +.shithub-org-people-main { | |
| 2326 | + min-width: 0; | |
| 2327 | +} | |
| 2328 | +.shithub-org-people-toolbar { | |
| 2329 | + display: flex; | |
| 2330 | + align-items: flex-start; | |
| 2331 | + justify-content: space-between; | |
| 2332 | + gap: 0.75rem; | |
| 2333 | + margin-bottom: 1rem; | |
| 2334 | +} | |
| 2335 | +.shithub-org-people-search { | |
| 2336 | + position: relative; | |
| 2337 | + flex: 1 1 360px; | |
| 2338 | + max-width: 480px; | |
| 2339 | +} | |
| 2340 | +.shithub-org-people-search svg { | |
| 2341 | + position: absolute; | |
| 2342 | + top: 50%; | |
| 2343 | + left: 0.75rem; | |
| 2344 | + width: 16px; | |
| 2345 | + height: 16px; | |
| 2346 | + color: var(--fg-muted); | |
| 2347 | + transform: translateY(-50%); | |
| 2348 | + pointer-events: none; | |
| 2349 | +} | |
| 2350 | +.shithub-org-people-search input, | |
| 2351 | +.shithub-org-people-invite-panel input, | |
| 2352 | +.shithub-org-people-invite-panel select, | |
| 2353 | +.shithub-org-people-actions select { | |
| 2354 | + width: 100%; | |
| 2355 | + min-height: 34px; | |
| 2356 | + border: 1px solid var(--border-default); | |
| 2357 | + border-radius: 6px; | |
| 2358 | + background: var(--canvas-default); | |
| 2359 | + color: var(--fg-default); | |
| 2360 | + font: inherit; | |
| 2361 | +} | |
| 2362 | +.shithub-org-people-search input { | |
| 2363 | + padding: 0.35rem 0.75rem 0.35rem 2.25rem; | |
| 2364 | +} | |
| 2365 | +.shithub-org-people-invite { | |
| 2366 | + position: relative; | |
| 2367 | + flex: 0 0 auto; | |
| 2368 | +} | |
| 2369 | +.shithub-org-people-invite > summary { | |
| 2370 | + list-style: none; | |
| 2371 | + cursor: pointer; | |
| 2372 | +} | |
| 2373 | +.shithub-org-people-invite > summary::-webkit-details-marker { | |
| 2374 | + display: none; | |
| 2375 | +} | |
| 2376 | +.shithub-org-people-invite-panel { | |
| 2377 | + position: absolute; | |
| 2378 | + right: 0; | |
| 2379 | + top: calc(100% + 0.45rem); | |
| 2380 | + z-index: 25; | |
| 2381 | + display: grid; | |
| 2382 | + gap: 0.75rem; | |
| 2383 | + width: min(360px, calc(100vw - 2rem)); | |
| 2384 | + padding: 1rem; | |
| 2385 | + border: 1px solid var(--border-default); | |
| 2386 | + border-radius: 8px; | |
| 2387 | + background: var(--canvas-default); | |
| 2388 | + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); | |
| 2389 | +} | |
| 2390 | +.shithub-org-people-invite-panel label { | |
| 2391 | + display: grid; | |
| 2392 | + gap: 0.35rem; | |
| 2393 | + color: var(--fg-default); | |
| 2394 | + font-size: 0.875rem; | |
| 2395 | + font-weight: 600; | |
| 2396 | +} | |
| 2397 | +.shithub-org-people-invite-panel input, | |
| 2398 | +.shithub-org-people-invite-panel select, | |
| 2399 | +.shithub-org-people-actions select { | |
| 2400 | + padding: 0.35rem 0.65rem; | |
| 2401 | +} | |
| 2402 | +.shithub-org-people-card { | |
| 2403 | + overflow: hidden; | |
| 2404 | + border: 1px solid var(--border-default); | |
| 2405 | + border-radius: 6px; | |
| 2406 | + background: var(--canvas-default); | |
| 2407 | +} | |
| 2408 | +.shithub-org-people-list { | |
| 2409 | + list-style: none; | |
| 2410 | + padding: 0; | |
| 2411 | + margin: 0; | |
| 2412 | +} | |
| 2413 | +.shithub-org-people-row { | |
| 2414 | + display: grid; | |
| 2415 | + grid-template-columns: 64px minmax(0, 1fr) auto auto; | |
| 2416 | + gap: 0.75rem; | |
| 2417 | + align-items: center; | |
| 2418 | + min-height: 76px; | |
| 2419 | + padding: 0.75rem 1rem; | |
| 2420 | + border-top: 1px solid var(--border-default); | |
| 2421 | +} | |
| 2422 | +.shithub-org-people-row:first-child { | |
| 2423 | + border-top: 0; | |
| 2424 | +} | |
| 2425 | +.shithub-org-people-avatar { | |
| 2426 | + display: inline-flex; | |
| 2427 | + width: 48px; | |
| 2428 | + height: 48px; | |
| 2429 | +} | |
| 2430 | +.shithub-org-people-avatar img { | |
| 2431 | + width: 48px; | |
| 2432 | + height: 48px; | |
| 2433 | + border-radius: 50%; | |
| 2434 | + border: 1px solid var(--border-muted); | |
| 2435 | + background: var(--canvas-subtle); | |
| 2436 | +} | |
| 2437 | +.shithub-org-people-member { | |
| 2438 | + display: grid; | |
| 2439 | + min-width: 0; | |
| 2440 | + line-height: 1.35; | |
| 2441 | +} | |
| 2442 | +.shithub-org-people-name { | |
| 2443 | + overflow: hidden; | |
| 2444 | + color: var(--fg-default); | |
| 2445 | + font-size: 1rem; | |
| 2446 | + font-weight: 600; | |
| 2447 | + text-overflow: ellipsis; | |
| 2448 | + white-space: nowrap; | |
| 2449 | +} | |
| 2450 | +.shithub-org-people-username { | |
| 2451 | + overflow: hidden; | |
| 2452 | + color: var(--fg-muted); | |
| 2453 | + font-size: 0.875rem; | |
| 2454 | + text-overflow: ellipsis; | |
| 2455 | + white-space: nowrap; | |
| 2456 | +} | |
| 2457 | +.shithub-org-people-meta { | |
| 2458 | + display: flex; | |
| 2459 | + align-items: center; | |
| 2460 | + justify-content: flex-end; | |
| 2461 | + gap: 0.65rem; | |
| 2462 | + color: var(--fg-muted); | |
| 2463 | + font-size: 0.875rem; | |
| 2464 | + white-space: nowrap; | |
| 2465 | +} | |
| 2466 | +.shithub-org-people-actions { | |
| 2467 | + display: flex; | |
| 2468 | + align-items: center; | |
| 2469 | + justify-content: flex-end; | |
| 2470 | + gap: 0.5rem; | |
| 2471 | +} | |
| 2472 | +.shithub-org-people-actions form { | |
| 2473 | + margin: 0; | |
| 2474 | +} | |
| 2475 | +.shithub-org-people-actions select { | |
| 2476 | + min-width: 104px; | |
| 2477 | + background: var(--canvas-subtle); | |
| 2478 | +} | |
| 2479 | +.shithub-org-people-empty { | |
| 2480 | + display: grid; | |
| 2481 | + place-items: center; | |
| 2482 | + gap: 0.75rem; | |
| 2483 | + min-height: 190px; | |
| 2484 | + padding: 2rem; | |
| 2485 | + color: var(--fg-muted); | |
| 2486 | + text-align: center; | |
| 2487 | +} | |
| 2488 | +.shithub-org-people-empty svg { | |
| 2489 | + width: 32px; | |
| 2490 | + height: 32px; | |
| 2491 | +} | |
| 2492 | +.shithub-org-people-empty h2 { | |
| 2493 | + margin: 0; | |
| 2494 | + color: var(--fg-default); | |
| 2495 | + font-size: 1.15rem; | |
| 2496 | +} | |
| 2497 | +.shithub-org-pending { | |
| 2498 | + margin-top: 1.5rem; | |
| 2499 | +} | |
| 2500 | +.shithub-org-pending h2 { | |
| 2501 | + margin: 0 0 0.75rem; | |
| 2502 | + font-size: 1rem; | |
| 2503 | +} | |
| 2504 | +.shithub-org-pending-list { | |
| 2505 | + overflow: hidden; | |
| 2506 | + list-style: none; | |
| 2507 | + padding: 0; | |
| 2508 | + margin: 0; | |
| 2509 | + border: 1px solid var(--border-default); | |
| 2510 | + border-radius: 6px; | |
| 2511 | + background: var(--canvas-default); | |
| 2512 | +} | |
| 2513 | +.shithub-org-pending-list li { | |
| 2514 | + display: flex; | |
| 2515 | + align-items: center; | |
| 2516 | + gap: 0.75rem; | |
| 2517 | + padding: 0.75rem 1rem; | |
| 2518 | + border-top: 1px solid var(--border-default); | |
| 2519 | +} | |
| 2520 | +.shithub-org-pending-list li:first-child { | |
| 2521 | + border-top: 0; | |
| 2522 | +} | |
| 2228 | 2523 | .shithub-modal-open { |
| 2229 | 2524 | overflow: hidden; |
| 2230 | 2525 | } |
@@ -2374,6 +2669,7 @@ code { | ||
| 2374 | 2669 | @media (max-width: 960px) { |
| 2375 | 2670 | .shithub-org-hero-inner, |
| 2376 | 2671 | .shithub-org-layout, |
| 2672 | + .shithub-org-people-layout, | |
| 2377 | 2673 | .shithub-org-repo-head { |
| 2378 | 2674 | grid-template-columns: 1fr; |
| 2379 | 2675 | } |
@@ -2388,6 +2684,28 @@ code { | ||
| 2388 | 2684 | .shithub-org-repo-row { |
| 2389 | 2685 | grid-template-columns: 1fr; |
| 2390 | 2686 | } |
| 2687 | + .shithub-org-people-toolbar, | |
| 2688 | + .shithub-org-people-row, | |
| 2689 | + .shithub-org-people-meta, | |
| 2690 | + .shithub-org-people-actions { | |
| 2691 | + align-items: stretch; | |
| 2692 | + } | |
| 2693 | + .shithub-org-people-toolbar { | |
| 2694 | + flex-direction: column; | |
| 2695 | + } | |
| 2696 | + .shithub-org-people-search { | |
| 2697 | + width: 100%; | |
| 2698 | + max-width: none; | |
| 2699 | + } | |
| 2700 | + .shithub-org-people-row { | |
| 2701 | + grid-template-columns: 48px minmax(0, 1fr); | |
| 2702 | + } | |
| 2703 | + .shithub-org-people-meta, | |
| 2704 | + .shithub-org-people-actions { | |
| 2705 | + grid-column: 1 / -1; | |
| 2706 | + justify-content: flex-start; | |
| 2707 | + flex-wrap: wrap; | |
| 2708 | + } | |
| 2391 | 2709 | .shithub-org-repo-spark { |
| 2392 | 2710 | display: none; |
| 2393 | 2711 | } |
@@ -2403,7 +2721,12 @@ code { | ||
| 2403 | 2721 | padding-inline: 0.5rem; |
| 2404 | 2722 | } |
| 2405 | 2723 | .shithub-org-layout, |
| 2406 | - .shithub-org-hero { | |
| 2724 | + .shithub-org-hero, | |
| 2725 | + .shithub-org-pagehead-inner, | |
| 2726 | + .shithub-org-people-layout { | |
| 2727 | + padding-inline: 0.75rem; | |
| 2728 | + } | |
| 2729 | + .shithub-org-pagehead .shithub-org-nav { | |
| 2407 | 2730 | padding-inline: 0.75rem; |
| 2408 | 2731 | } |
| 2409 | 2732 | } |
internal/web/templates/orgs/people.htmlmodified@@ -1,74 +1,133 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | 2 | <section class="shithub-org-people"> |
| 3 | - <header class="shithub-org-profile-head"> | |
| 4 | - <h1>{{ .Org.DisplayName }} · People</h1> | |
| 5 | - <p class="shithub-meta">@{{ .Org.Slug }}</p> | |
| 3 | + <header class="shithub-org-pagehead"> | |
| 4 | + <div class="shithub-org-pagehead-inner"> | |
| 5 | + <a class="shithub-org-pagehead-title" href="/{{ .Org.Slug }}"> | |
| 6 | + <img src="{{ .AvatarURL }}" alt="" width="30" height="30"> | |
| 7 | + <span>{{ if .Org.DisplayName }}{{ .Org.DisplayName }}{{ else }}{{ .Org.Slug }}{{ end }}</span> | |
| 8 | + </a> | |
| 9 | + </div> | |
| 10 | + <nav class="shithub-org-nav" aria-label="Organization"> | |
| 11 | + <a href="/{{ .Org.Slug }}" class="shithub-org-nav-item">{{ octicon "home" }} Overview</a> | |
| 12 | + <a href="/{{ .Org.Slug }}#org-repositories" class="shithub-org-nav-item">{{ octicon "repo" }} Repositories</a> | |
| 13 | + <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "table" }} Projects</span> | |
| 14 | + <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "package" }} Packages</span> | |
| 15 | + <a href="/{{ .Org.Slug }}/teams" class="shithub-org-nav-item">{{ octicon "people" }} Teams</a> | |
| 16 | + <a href="/{{ .Org.Slug }}/people" class="shithub-org-nav-item is-active" aria-current="page">{{ octicon "person" }} People <span class="shithub-tab-count">{{ .MemberCount }}</span></a> | |
| 17 | + <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "shield-check" }} Security and quality</span> | |
| 18 | + <span class="shithub-org-nav-item is-disabled" aria-disabled="true">{{ octicon "pulse" }} Insights</span> | |
| 19 | + {{ if .IsOwner }}<a href="/organizations/{{ .Org.Slug }}/settings/profile" class="shithub-org-nav-item">{{ octicon "gear" }} Settings</a>{{ end }} | |
| 20 | + </nav> | |
| 6 | 21 | </header> |
| 7 | 22 | |
| 8 | - {{ if .IsOwner }} | |
| 9 | - <section class="shithub-org-invite"> | |
| 10 | - <h2>Invite a new member</h2> | |
| 11 | - <form method="POST" action="/{{ .Org.Slug }}/people/invite"> | |
| 12 | - <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 13 | - <label> | |
| 14 | - <span>Username or email</span> | |
| 15 | - <input type="text" name="target" required placeholder="@username or someone@example.com"> | |
| 16 | - </label> | |
| 17 | - <label> | |
| 18 | - <span>Role</span> | |
| 19 | - <select name="role"> | |
| 20 | - <option value="member" selected>Member</option> | |
| 21 | - <option value="owner">Owner</option> | |
| 22 | - </select> | |
| 23 | - </label> | |
| 24 | - <button type="submit" class="shithub-button shithub-button-primary">Invite</button> | |
| 25 | - </form> | |
| 26 | - </section> | |
| 27 | - {{ end }} | |
| 23 | + <div class="shithub-org-people-layout"> | |
| 24 | + <aside class="shithub-org-people-sidebar" aria-label="People navigation"> | |
| 25 | + <h1>People</h1> | |
| 26 | + <nav class="shithub-org-people-menu" aria-labelledby="organization-people-label"> | |
| 27 | + <h2 id="organization-people-label">Organization permissions</h2> | |
| 28 | + <a href="/{{ .Org.Slug }}/people" class="is-selected" aria-current="page"> | |
| 29 | + <span>Members</span> | |
| 30 | + <span class="shithub-counter">{{ .MemberCount }}</span> | |
| 31 | + </a> | |
| 32 | + {{ if .IsOwner }} | |
| 33 | + <a href="#pending-invitations"> | |
| 34 | + <span>Pending invitations</span> | |
| 35 | + <span class="shithub-counter">{{ .PendingCount }}</span> | |
| 36 | + </a> | |
| 37 | + {{ end }} | |
| 38 | + </nav> | |
| 39 | + </aside> | |
| 40 | + | |
| 41 | + <div class="shithub-org-people-main"> | |
| 42 | + <div class="shithub-org-people-toolbar"> | |
| 43 | + <form method="GET" action="/{{ .Org.Slug }}/people" class="shithub-org-people-search" role="search"> | |
| 44 | + {{ octicon "search" }} | |
| 45 | + <input type="search" name="query" value="{{ .Query }}" placeholder="Find a member..." aria-label="Find a member" autocomplete="off"> | |
| 46 | + </form> | |
| 28 | 47 | |
| 29 | - <section class="shithub-org-members"> | |
| 30 | - <h2>Members ({{ len .Members }})</h2> | |
| 31 | - <table class="shithub-table"> | |
| 32 | - <thead><tr><th>User</th><th>Role</th><th>Joined</th>{{ if .IsOwner }}<th></th>{{ end }}</tr></thead> | |
| 33 | - <tbody> | |
| 34 | - {{ range .Members }} | |
| 35 | - <tr> | |
| 36 | - <td><a href="/{{ .Username }}">@{{ .Username }}</a> {{ if .DisplayName }}<span class="shithub-meta">— {{ .DisplayName }}</span>{{ end }}</td> | |
| 37 | - <td>{{ .Role }}</td> | |
| 38 | - <td>{{ relativeTime .JoinedAt.Time }}</td> | |
| 39 | - {{ if $.IsOwner }} | |
| 40 | - <td> | |
| 41 | - <form method="POST" action="/{{ $.Org.Slug }}/people/{{ .UserID }}/role" style="display:inline"> | |
| 42 | - <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> | |
| 43 | - <select name="role" onchange="this.form.submit()" aria-label="Change role"> | |
| 44 | - <option value="member" {{ if eq (printf "%s" .Role) "member" }}selected{{ end }}>Member</option> | |
| 45 | - <option value="owner" {{ if eq (printf "%s" .Role) "owner" }}selected{{ end }}>Owner</option> | |
| 48 | + {{ if .CanManagePeople }} | |
| 49 | + <details class="shithub-org-people-invite"> | |
| 50 | + <summary class="shithub-button shithub-button-primary">{{ octicon "plus" }} Invite member</summary> | |
| 51 | + <form method="POST" action="/{{ .Org.Slug }}/people/invite" class="shithub-org-people-invite-panel"> | |
| 52 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 53 | + <label> | |
| 54 | + <span>Username or email</span> | |
| 55 | + <input type="text" name="target" required placeholder="@username or someone@example.com"> | |
| 56 | + </label> | |
| 57 | + <label> | |
| 58 | + <span>Role</span> | |
| 59 | + <select name="role"> | |
| 60 | + <option value="member" selected>Member</option> | |
| 61 | + <option value="owner">Owner</option> | |
| 46 | 62 | </select> |
| 47 | - </form> | |
| 48 | - <form method="POST" action="/{{ $.Org.Slug }}/people/{{ .UserID }}/remove" style="display:inline"> | |
| 49 | - <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> | |
| 50 | - <button type="submit" class="shithub-button">Remove</button> | |
| 51 | - </form> | |
| 52 | - </td> | |
| 63 | + </label> | |
| 64 | + <button type="submit" class="shithub-button shithub-button-primary">Send invitation</button> | |
| 65 | + </form> | |
| 66 | + </details> | |
| 67 | + {{ end }} | |
| 68 | + </div> | |
| 69 | + | |
| 70 | + <section id="org-members-table" class="shithub-org-people-card" aria-label="Organization members"> | |
| 71 | + {{ if .Members }} | |
| 72 | + <ul class="shithub-org-people-list"> | |
| 73 | + {{ range .Members }} | |
| 74 | + <li class="shithub-org-people-row"> | |
| 75 | + <a class="shithub-org-people-avatar" href="/{{ .Username }}"> | |
| 76 | + <img src="/avatars/{{ .Username }}" alt="" width="48" height="48"> | |
| 77 | + </a> | |
| 78 | + <div class="shithub-org-people-member"> | |
| 79 | + <a class="shithub-org-people-name" href="/{{ .Username }}">{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</a> | |
| 80 | + <span class="shithub-org-people-username">{{ .Username }}</span> | |
| 81 | + </div> | |
| 82 | + <div class="shithub-org-people-meta"> | |
| 83 | + <span class="shithub-pill">{{ if eq (printf "%s" .Role) "owner" }}Owner{{ else }}Member{{ end }}</span> | |
| 84 | + <span>Joined {{ relativeTime .JoinedAt.Time }}</span> | |
| 85 | + </div> | |
| 86 | + {{ if $.IsOwner }} | |
| 87 | + <div class="shithub-org-people-actions"> | |
| 88 | + <form method="POST" action="/{{ $.Org.Slug }}/people/{{ .UserID }}/role"> | |
| 89 | + <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> | |
| 90 | + <select name="role" onchange="this.form.submit()" aria-label="Change role for {{ .Username }}"> | |
| 91 | + <option value="member" {{ if eq (printf "%s" .Role) "member" }}selected{{ end }}>Member</option> | |
| 92 | + <option value="owner" {{ if eq (printf "%s" .Role) "owner" }}selected{{ end }}>Owner</option> | |
| 93 | + </select> | |
| 94 | + </form> | |
| 95 | + <form method="POST" action="/{{ $.Org.Slug }}/people/{{ .UserID }}/remove"> | |
| 96 | + <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> | |
| 97 | + <button type="submit" class="shithub-button">Remove</button> | |
| 98 | + </form> | |
| 99 | + </div> | |
| 100 | + {{ end }} | |
| 101 | + </li> | |
| 102 | + {{ end }} | |
| 103 | + </ul> | |
| 104 | + {{ else }} | |
| 105 | + <div class="shithub-org-people-empty"> | |
| 106 | + {{ octicon "organization" }} | |
| 107 | + {{ if .HasQuery }} | |
| 108 | + <h2>No members matched your search.</h2> | |
| 109 | + {{ else }} | |
| 110 | + <h2>This organization has no members.</h2> | |
| 53 | 111 | {{ end }} |
| 54 | - </tr> | |
| 112 | + </div> | |
| 55 | 113 | {{ end }} |
| 56 | - </tbody> | |
| 57 | - </table> | |
| 58 | - </section> | |
| 114 | + </section> | |
| 59 | 115 | |
| 60 | - {{ if and .IsOwner .Pending }} | |
| 61 | - <section class="shithub-org-pending"> | |
| 62 | - <h2>Pending invitations</h2> | |
| 63 | - <ul> | |
| 64 | - {{ range .Pending }} | |
| 65 | - <li> | |
| 66 | - {{ if .TargetUsername.Valid }}@{{ .TargetUsername.String }}{{ else }}{{ .TargetEmail.String }}{{ end }} | |
| 67 | - — {{ .Role }} — invited {{ relativeTime .CreatedAt.Time }} | |
| 68 | - </li> | |
| 69 | - {{ end }} | |
| 70 | - </ul> | |
| 71 | - </section> | |
| 72 | - {{ end }} | |
| 116 | + {{ if and .IsOwner .Pending }} | |
| 117 | + <section id="pending-invitations" class="shithub-org-pending"> | |
| 118 | + <h2>Pending invitations</h2> | |
| 119 | + <ul class="shithub-org-pending-list"> | |
| 120 | + {{ range .Pending }} | |
| 121 | + <li> | |
| 122 | + <span>{{ if .TargetUsername.Valid }}@{{ .TargetUsername.String }}{{ else }}{{ .TargetEmail.String }}{{ end }}</span> | |
| 123 | + <span class="shithub-pill">{{ if eq (printf "%s" .Role) "owner" }}Owner{{ else }}Member{{ end }}</span> | |
| 124 | + <span class="shithub-org-people-username">Invited {{ relativeTime .CreatedAt.Time }}</span> | |
| 125 | + </li> | |
| 126 | + {{ end }} | |
| 127 | + </ul> | |
| 128 | + </section> | |
| 129 | + {{ end }} | |
| 130 | + </div> | |
| 131 | + </div> | |
| 73 | 132 | </section> |
| 74 | 133 | {{- end }} |