tenseleyflow/shithub / ebeab2e

Browse files

Align org people page with GitHub

Authored by espadonne
SHA
ebeab2e6390040cfbecd08a5bdc8a935b841c56c
Parents
047f0ac
Tree
ba1c702

5 changed files

StatusFile+-
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
6060
 picker entries, so unauthorized org hints fall back to the viewer's
6161
 personal namespace.
6262
 
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
+
6371
 Organization owners see a "Customize pins" modal on the overview. The
6472
 picker mirrors GitHub's public-profile rule: it offers only public
6573
 org-owned repos, has a live text filter, caps selections at six, and
internal/web/handlers/orgs/orgs.gomodified
@@ -22,6 +22,7 @@ import (
2222
 	"errors"
2323
 	"log/slog"
2424
 	"net/http"
25
+	"net/url"
2526
 	"strconv"
2627
 	"strings"
2728
 
@@ -190,6 +191,8 @@ func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) {
190191
 		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
191192
 		return
192193
 	}
194
+	query := strings.TrimSpace(r.URL.Query().Get("query"))
195
+	filteredMembers := filterOrgMembers(members, query)
193196
 	var pending []orgsdb.ListPendingInvitationsForOrgRow
194197
 	isOwner := false
195198
 	if !viewer.IsAnonymous() {
@@ -199,15 +202,37 @@ func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) {
199202
 		}
200203
 	}
201204
 	_ = 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,
208217
 	})
209218
 }
210219
 
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
+
211236
 func (h *Handlers) invite(w http.ResponseWriter, r *http.Request) {
212237
 	org, ok := h.orgFromSlug(w, r)
213238
 	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 {
22252225
   color: var(--fg-default);
22262226
   font-size: 1rem;
22272227
 }
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
+}
22282523
 .shithub-modal-open {
22292524
   overflow: hidden;
22302525
 }
@@ -2374,6 +2669,7 @@ code {
23742669
 @media (max-width: 960px) {
23752670
   .shithub-org-hero-inner,
23762671
   .shithub-org-layout,
2672
+  .shithub-org-people-layout,
23772673
   .shithub-org-repo-head {
23782674
     grid-template-columns: 1fr;
23792675
   }
@@ -2388,6 +2684,28 @@ code {
23882684
   .shithub-org-repo-row {
23892685
     grid-template-columns: 1fr;
23902686
   }
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
+  }
23912709
   .shithub-org-repo-spark {
23922710
     display: none;
23932711
   }
@@ -2403,7 +2721,12 @@ code {
24032721
     padding-inline: 0.5rem;
24042722
   }
24052723
   .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 {
24072730
     padding-inline: 0.75rem;
24082731
   }
24092732
 }
internal/web/templates/orgs/people.htmlmodified
@@ -1,74 +1,133 @@
11
 {{ define "page" -}}
22
 <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>
621
   </header>
722
 
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>
2847
 
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>
4662
               </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>
53111
           {{ end }}
54
-        </tr>
112
+        </div>
55113
         {{ end }}
56
-      </tbody>
57
-    </table>
58
-  </section>
114
+      </section>
59115
 
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>
73132
 </section>
74133
 {{- end }}