tenseleyflow/shithub / 949c68a

Browse files

Add global dashboard list surfaces

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
949c68a0cfe8df9efffd526a45c062dda5db954a
Parents
0d477a4
Tree
a30134f

9 changed files

StatusFile+-
A internal/web/handlers/global_dashboard.go 692 0
A internal/web/handlers/global_dashboard_test.go 58 0
M internal/web/handlers/handlers.go 9 0
M internal/web/render/octicons.go 4 0
M internal/web/static/css/shithub.css 514 3
A internal/web/templates/dashboard/issues.html 70 0
A internal/web/templates/dashboard/new_issue.html 42 0
A internal/web/templates/dashboard/pulls.html 64 0
A internal/web/templates/dashboard/repos.html 72 0
internal/web/handlers/global_dashboard.goadded
@@ -0,0 +1,692 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+	"log/slog"
9
+	"net/http"
10
+	"net/url"
11
+	"strconv"
12
+	"strings"
13
+	"time"
14
+
15
+	"github.com/go-chi/chi/v5"
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+
18
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
19
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
+	"github.com/tenseleyFlow/shithub/internal/web/render"
21
+)
22
+
23
+const globalDashboardLimit = 50
24
+
25
+type globalNavHandler struct {
26
+	render *render.Renderer
27
+	logger *slog.Logger
28
+	pool   *pgxpool.Pool
29
+}
30
+
31
+type globalNavTab struct {
32
+	Key      string
33
+	Label    string
34
+	Icon     string
35
+	Href     string
36
+	Selected bool
37
+	Count    int64
38
+	Meta     string
39
+}
40
+
41
+type globalIssueCounts struct {
42
+	Total  int64
43
+	Open   int64
44
+	Closed int64
45
+}
46
+
47
+type globalIssueRow struct {
48
+	ID           int64
49
+	Owner        string
50
+	RepoName     string
51
+	Number       int64
52
+	Title        string
53
+	State        string
54
+	Kind         string
55
+	AuthorName   string
56
+	URL          string
57
+	RepoURL      string
58
+	UpdatedAt    time.Time
59
+	CommentCount int64
60
+}
61
+
62
+type globalRepoRow struct {
63
+	ID              int64
64
+	Owner           string
65
+	Name            string
66
+	Description     string
67
+	Visibility      string
68
+	PrimaryLanguage string
69
+	LicenseKey      string
70
+	StarCount       int64
71
+	ForkCount       int64
72
+	WatcherCount    int64
73
+	IsFork          bool
74
+	UpdatedAt       time.Time
75
+	URL             string
76
+}
77
+
78
+func (h globalNavHandler) RedirectIssues(w http.ResponseWriter, r *http.Request) {
79
+	http.Redirect(w, r, "/issues/assigned", http.StatusSeeOther)
80
+}
81
+
82
+func (h globalNavHandler) ServeIssues(w http.ResponseWriter, r *http.Request) {
83
+	view := chi.URLParam(r, "view")
84
+	if !validIssueView(view) {
85
+		http.Redirect(w, r, "/issues/assigned", http.StatusSeeOther)
86
+		return
87
+	}
88
+	viewer := middleware.CurrentUserFromContext(r.Context())
89
+	rawQuery := strings.TrimSpace(r.URL.Query().Get("q"))
90
+	state := globalStateFromRequest(r, rawQuery)
91
+	displayQuery := rawQuery
92
+	if displayQuery == "" {
93
+		displayQuery = defaultIssueQuery(view, state)
94
+	}
95
+	searchText := globalIssueSearchText(rawQuery)
96
+
97
+	items, counts, err := h.listIssues(r.Context(), viewer.PolicyActor(), viewer.ID, "issue", view, state, searchText)
98
+	if err != nil {
99
+		h.logError(r.Context(), "global issues list", err)
100
+		h.render.HTTPError(w, r, http.StatusInternalServerError, "")
101
+		return
102
+	}
103
+
104
+	data := map[string]any{
105
+		"Title":        "Issues",
106
+		"Heading":      issueViewLabel(view),
107
+		"Query":        displayQuery,
108
+		"State":        state,
109
+		"StateTabs":    stateTabs(r.URL.Path, rawQuery, state, counts),
110
+		"Views":        issueViewTabs(view, rawQuery, state),
111
+		"Issues":       items,
112
+		"Counts":       counts,
113
+		"ResultCount":  countForState(counts, state),
114
+		"EmptyTitle":   "No issues matched this view",
115
+		"EmptyMessage": "Try another filter or open a repository to create a new issue.",
116
+		"NewHref":      "/issues/new",
117
+	}
118
+	if err := h.render.RenderPage(w, r, "dashboard/issues", data); err != nil {
119
+		h.logError(r.Context(), "render global issues", err)
120
+		http.Error(w, "internal server error", http.StatusInternalServerError)
121
+	}
122
+}
123
+
124
+func (h globalNavHandler) ServeNewIssue(w http.ResponseWriter, r *http.Request) {
125
+	viewer := middleware.CurrentUserFromContext(r.Context())
126
+	searchText := strings.TrimSpace(r.URL.Query().Get("q"))
127
+	repos, total, err := h.listRepos(r.Context(), viewer.PolicyActor(), viewer.ID, "contributions", searchText, true)
128
+	if err != nil {
129
+		h.logError(r.Context(), "global new issue repos", err)
130
+		h.render.HTTPError(w, r, http.StatusInternalServerError, "")
131
+		return
132
+	}
133
+	data := map[string]any{
134
+		"Title":       "New issue",
135
+		"Heading":     "New issue",
136
+		"Query":       searchText,
137
+		"Repos":       repos,
138
+		"TotalCount":  total,
139
+		"EmptyTitle":  "No repositories with issues enabled",
140
+		"EmptyBody":   "Create a repository or choose one with issues enabled before opening an issue.",
141
+		"ChooseIssue": true,
142
+	}
143
+	if err := h.render.RenderPage(w, r, "dashboard/new_issue", data); err != nil {
144
+		h.logError(r.Context(), "render global new issue", err)
145
+		http.Error(w, "internal server error", http.StatusInternalServerError)
146
+	}
147
+}
148
+
149
+func (h globalNavHandler) ServePulls(w http.ResponseWriter, r *http.Request) {
150
+	view := normalizePullView(r.URL.Query().Get("view"))
151
+	viewer := middleware.CurrentUserFromContext(r.Context())
152
+	rawQuery := strings.TrimSpace(r.URL.Query().Get("q"))
153
+	state := globalStateFromRequest(r, rawQuery)
154
+	displayQuery := rawQuery
155
+	if displayQuery == "" {
156
+		displayQuery = defaultPullQuery(view, state, viewer.Username)
157
+	}
158
+	searchText := globalIssueSearchText(rawQuery)
159
+
160
+	items, counts, err := h.listIssues(r.Context(), viewer.PolicyActor(), viewer.ID, "pr", view, state, searchText)
161
+	if err != nil {
162
+		h.logError(r.Context(), "global pulls list", err)
163
+		h.render.HTTPError(w, r, http.StatusInternalServerError, "")
164
+		return
165
+	}
166
+
167
+	data := map[string]any{
168
+		"Title":       "Pull requests",
169
+		"Heading":     "Pull requests",
170
+		"Query":       displayQuery,
171
+		"State":       state,
172
+		"StateTabs":   stateTabs("/pulls", rawQuery, state, counts),
173
+		"Views":       pullViewTabs(view, rawQuery, state),
174
+		"Pulls":       items,
175
+		"Counts":      counts,
176
+		"ResultCount": countForState(counts, state),
177
+		"EmptyTitle":  "No pull requests matched this view",
178
+	}
179
+	if err := h.render.RenderPage(w, r, "dashboard/pulls", data); err != nil {
180
+		h.logError(r.Context(), "render global pulls", err)
181
+		http.Error(w, "internal server error", http.StatusInternalServerError)
182
+	}
183
+}
184
+
185
+func (h globalNavHandler) ServeRepos(w http.ResponseWriter, r *http.Request) {
186
+	view := normalizeRepoView(r.URL.Query().Get("view"))
187
+	viewer := middleware.CurrentUserFromContext(r.Context())
188
+	searchText := strings.TrimSpace(r.URL.Query().Get("q"))
189
+	repos, total, err := h.listRepos(r.Context(), viewer.PolicyActor(), viewer.ID, view, searchText, false)
190
+	if err != nil {
191
+		h.logError(r.Context(), "global repos list", err)
192
+		h.render.HTTPError(w, r, http.StatusInternalServerError, "")
193
+		return
194
+	}
195
+
196
+	data := map[string]any{
197
+		"Title":       "Repositories",
198
+		"Heading":     repoViewLabel(view),
199
+		"Query":       searchText,
200
+		"Views":       repoViewTabs(view, searchText),
201
+		"Repos":       repos,
202
+		"TotalCount":  total,
203
+		"EmptyTitle":  "No repositories matched this view",
204
+		"EmptyBody":   "Create a repository or adjust your filters.",
205
+		"NewRepoHref": "/new",
206
+	}
207
+	if err := h.render.RenderPage(w, r, "dashboard/repos", data); err != nil {
208
+		h.logError(r.Context(), "render global repos", err)
209
+		http.Error(w, "internal server error", http.StatusInternalServerError)
210
+	}
211
+}
212
+
213
+func (h globalNavHandler) listIssues(ctx context.Context, actor policy.Actor, viewerID int64, kind, view, state, searchText string) ([]globalIssueRow, globalIssueCounts, error) {
214
+	if h.pool == nil {
215
+		return nil, globalIssueCounts{}, fmt.Errorf("database unavailable")
216
+	}
217
+
218
+	countWhere, countArgs := buildGlobalIssueWhere(actor, viewerID, kind, view, "", searchText)
219
+	countSQL := fmt.Sprintf(`
220
+SELECT
221
+  COUNT(*)::bigint,
222
+  COUNT(*) FILTER (WHERE i.state = 'open')::bigint,
223
+  COUNT(*) FILTER (WHERE i.state = 'closed')::bigint
224
+FROM issues i
225
+JOIN repos r ON r.id = i.repo_id
226
+LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
227
+LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
228
+WHERE %s`, countWhere)
229
+	var counts globalIssueCounts
230
+	if err := h.pool.QueryRow(ctx, countSQL, countArgs...).Scan(&counts.Total, &counts.Open, &counts.Closed); err != nil {
231
+		return nil, counts, err
232
+	}
233
+
234
+	where, args := buildGlobalIssueWhere(actor, viewerID, kind, view, state, searchText)
235
+	limitPlaceholder := nextPlaceholder(&args, globalDashboardLimit)
236
+	ownerExpr := globalRepoOwnerExpr()
237
+	listSQL := fmt.Sprintf(`
238
+SELECT
239
+  i.id,
240
+  %s AS owner,
241
+  r.name::text AS repo_name,
242
+  i.number,
243
+  i.title,
244
+  i.state::text AS state,
245
+  i.kind::text AS kind,
246
+  COALESCE(author.username::text, '') AS author_name,
247
+  i.updated_at,
248
+  (SELECT COUNT(*)::bigint FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count
249
+FROM issues i
250
+JOIN repos r ON r.id = i.repo_id
251
+LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
252
+LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
253
+LEFT JOIN users author ON author.id = i.author_user_id
254
+WHERE %s
255
+ORDER BY i.updated_at DESC, i.id DESC
256
+LIMIT %s`, ownerExpr, where, limitPlaceholder)
257
+
258
+	rows, err := h.pool.Query(ctx, listSQL, args...)
259
+	if err != nil {
260
+		return nil, counts, err
261
+	}
262
+	defer rows.Close()
263
+
264
+	var out []globalIssueRow
265
+	for rows.Next() {
266
+		var row globalIssueRow
267
+		if err := rows.Scan(
268
+			&row.ID,
269
+			&row.Owner,
270
+			&row.RepoName,
271
+			&row.Number,
272
+			&row.Title,
273
+			&row.State,
274
+			&row.Kind,
275
+			&row.AuthorName,
276
+			&row.UpdatedAt,
277
+			&row.CommentCount,
278
+		); err != nil {
279
+			return nil, counts, err
280
+		}
281
+		row.RepoURL = "/" + row.Owner + "/" + row.RepoName
282
+		threadPath := "issues"
283
+		if row.Kind == "pr" {
284
+			threadPath = "pulls"
285
+		}
286
+		row.URL = row.RepoURL + "/" + threadPath + "/" + strconv.FormatInt(row.Number, 10)
287
+		out = append(out, row)
288
+	}
289
+	if err := rows.Err(); err != nil {
290
+		return nil, counts, err
291
+	}
292
+	return out, counts, nil
293
+}
294
+
295
+func (h globalNavHandler) listRepos(ctx context.Context, actor policy.Actor, viewerID int64, view, searchText string, requireIssues bool) ([]globalRepoRow, int64, error) {
296
+	if h.pool == nil {
297
+		return nil, 0, fmt.Errorf("database unavailable")
298
+	}
299
+	where, args := buildGlobalRepoWhere(actor, viewerID, view, searchText, requireIssues)
300
+	countSQL := fmt.Sprintf(`
301
+SELECT COUNT(*)::bigint
302
+FROM repos r
303
+LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
304
+LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
305
+WHERE %s`, where)
306
+	var total int64
307
+	if err := h.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
308
+		return nil, 0, err
309
+	}
310
+
311
+	listWhere, listArgs := buildGlobalRepoWhere(actor, viewerID, view, searchText, requireIssues)
312
+	limitPlaceholder := nextPlaceholder(&listArgs, globalDashboardLimit)
313
+	ownerExpr := globalRepoOwnerExpr()
314
+	listSQL := fmt.Sprintf(`
315
+SELECT
316
+  r.id,
317
+  %s AS owner,
318
+  r.name::text AS name,
319
+  r.description,
320
+  r.visibility::text AS visibility,
321
+  COALESCE(r.primary_language, '') AS primary_language,
322
+  COALESCE(r.license_key, '') AS license_key,
323
+  r.star_count,
324
+  r.fork_count,
325
+  r.watcher_count,
326
+  (r.fork_of_repo_id IS NOT NULL) AS is_fork,
327
+  r.updated_at
328
+FROM repos r
329
+LEFT JOIN users owner_user ON owner_user.id = r.owner_user_id
330
+LEFT JOIN orgs owner_org ON owner_org.id = r.owner_org_id
331
+WHERE %s
332
+ORDER BY r.updated_at DESC, r.id DESC
333
+LIMIT %s`, ownerExpr, listWhere, limitPlaceholder)
334
+
335
+	rows, err := h.pool.Query(ctx, listSQL, listArgs...)
336
+	if err != nil {
337
+		return nil, total, err
338
+	}
339
+	defer rows.Close()
340
+
341
+	var out []globalRepoRow
342
+	for rows.Next() {
343
+		var row globalRepoRow
344
+		if err := rows.Scan(
345
+			&row.ID,
346
+			&row.Owner,
347
+			&row.Name,
348
+			&row.Description,
349
+			&row.Visibility,
350
+			&row.PrimaryLanguage,
351
+			&row.LicenseKey,
352
+			&row.StarCount,
353
+			&row.ForkCount,
354
+			&row.WatcherCount,
355
+			&row.IsFork,
356
+			&row.UpdatedAt,
357
+		); err != nil {
358
+			return nil, total, err
359
+		}
360
+		row.URL = "/" + row.Owner + "/" + row.Name
361
+		out = append(out, row)
362
+	}
363
+	if err := rows.Err(); err != nil {
364
+		return nil, total, err
365
+	}
366
+	return out, total, nil
367
+}
368
+
369
+func buildGlobalIssueWhere(actor policy.Actor, viewerID int64, kind, view, state, searchText string) (string, []any) {
370
+	visClause, visArgs := policy.VisibilityPredicate(actor, "r", 1)
371
+	args := append([]any{}, visArgs...)
372
+	clauses := []string{
373
+		visClause,
374
+		"((" + "r.owner_user_id IS NOT NULL AND owner_user.deleted_at IS NULL AND owner_user.suspended_at IS NULL" + ") OR (" + "r.owner_org_id IS NOT NULL AND owner_org.deleted_at IS NULL" + "))",
375
+	}
376
+
377
+	kindPlaceholder := nextPlaceholder(&args, kind)
378
+	clauses = append(clauses, "i.kind = "+kindPlaceholder+"::issue_kind")
379
+	if state != "" && state != "all" {
380
+		clauses = append(clauses, "i.state = "+nextPlaceholder(&args, state)+"::issue_state")
381
+	}
382
+	if searchText != "" {
383
+		pattern := "%" + strings.ToLower(searchText) + "%"
384
+		p := nextPlaceholder(&args, pattern)
385
+		ownerExpr := "LOWER(" + globalRepoOwnerExpr() + ")"
386
+		clauses = append(clauses, "(LOWER(i.title) LIKE "+p+" OR LOWER(i.body) LIKE "+p+" OR LOWER(r.name::text) LIKE "+p+" OR "+ownerExpr+" LIKE "+p+")")
387
+	}
388
+
389
+	uid := nextPlaceholder(&args, viewerID)
390
+	threadKind := func() string {
391
+		return nextPlaceholder(&args, kind) + "::notification_thread_kind"
392
+	}
393
+	switch view {
394
+	case "assigned":
395
+		clauses = append(clauses, "EXISTS (SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.user_id = "+uid+")")
396
+	case "created":
397
+		clauses = append(clauses, "i.author_user_id = "+uid)
398
+	case "mentioned":
399
+		tk := threadKind()
400
+		clauses = append(clauses, "(EXISTS (SELECT 1 FROM notifications n WHERE n.recipient_user_id = "+uid+" AND n.thread_kind = "+tk+" AND n.thread_id = i.id AND n.reason = 'mention') OR EXISTS (SELECT 1 FROM notification_threads nt WHERE nt.recipient_user_id = "+uid+" AND nt.thread_kind = "+tk+" AND nt.thread_id = i.id AND nt.reason = 'mention'))")
401
+	case "recent":
402
+		tk := threadKind()
403
+		clauses = append(clauses, "(i.author_user_id = "+uid+" OR EXISTS (SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.user_id = "+uid+") OR EXISTS (SELECT 1 FROM notification_threads nt WHERE nt.recipient_user_id = "+uid+" AND nt.thread_kind = "+tk+" AND nt.thread_id = i.id AND nt.subscribed = true))")
404
+	case "review-requests":
405
+		tk := threadKind()
406
+		clauses = append(clauses, "(EXISTS (SELECT 1 FROM notifications n WHERE n.recipient_user_id = "+uid+" AND n.thread_kind = "+tk+" AND n.thread_id = i.id AND n.reason = 'review_requested') OR EXISTS (SELECT 1 FROM notification_threads nt WHERE nt.recipient_user_id = "+uid+" AND nt.thread_kind = "+tk+" AND nt.thread_id = i.id AND nt.reason = 'review_requested'))")
407
+	default:
408
+		clauses = append(clauses, "i.author_user_id = "+uid)
409
+	}
410
+	return strings.Join(clauses, " AND "), args
411
+}
412
+
413
+func buildGlobalRepoWhere(actor policy.Actor, viewerID int64, view, searchText string, requireIssues bool) (string, []any) {
414
+	visClause, visArgs := policy.VisibilityPredicate(actor, "r", 1)
415
+	args := append([]any{}, visArgs...)
416
+	clauses := []string{
417
+		visClause,
418
+		"((r.owner_user_id IS NOT NULL AND owner_user.deleted_at IS NULL AND owner_user.suspended_at IS NULL) OR (r.owner_org_id IS NOT NULL AND owner_org.deleted_at IS NULL))",
419
+	}
420
+	if requireIssues {
421
+		clauses = append(clauses, "r.has_issues = true")
422
+	}
423
+	if searchText != "" {
424
+		pattern := "%" + strings.ToLower(searchText) + "%"
425
+		p := nextPlaceholder(&args, pattern)
426
+		ownerExpr := "LOWER(" + globalRepoOwnerExpr() + ")"
427
+		clauses = append(clauses, "(LOWER(r.name::text) LIKE "+p+" OR LOWER(r.description) LIKE "+p+" OR "+ownerExpr+" LIKE "+p+")")
428
+	}
429
+	uid := nextPlaceholder(&args, viewerID)
430
+	contributionClause := "(r.owner_user_id = " + uid + " OR r.owner_org_id IN (SELECT org_id FROM org_members WHERE user_id = " + uid + ") OR EXISTS (SELECT 1 FROM repo_collaborators rc WHERE rc.repo_id = r.id AND rc.user_id = " + uid + "))"
431
+	switch view {
432
+	case "mine":
433
+		clauses = append(clauses, "r.owner_user_id = "+uid)
434
+	case "forks":
435
+		clauses = append(clauses, "r.fork_of_repo_id IS NOT NULL", contributionClause)
436
+	case "admin":
437
+		clauses = append(clauses, "(r.owner_user_id = "+uid+" OR r.owner_org_id IN (SELECT org_id FROM org_members WHERE user_id = "+uid+" AND role = 'owner') OR EXISTS (SELECT 1 FROM repo_collaborators rc WHERE rc.repo_id = r.id AND rc.user_id = "+uid+" AND rc.role = 'admin'))")
438
+	default:
439
+		clauses = append(clauses, contributionClause)
440
+	}
441
+	return strings.Join(clauses, " AND "), args
442
+}
443
+
444
+func nextPlaceholder(args *[]any, v any) string {
445
+	*args = append(*args, v)
446
+	return "$" + strconv.Itoa(len(*args))
447
+}
448
+
449
+func globalRepoOwnerExpr() string {
450
+	return "COALESCE(owner_user.username::text, owner_org.slug::text, '')"
451
+}
452
+
453
+func validIssueView(view string) bool {
454
+	switch view {
455
+	case "assigned", "created", "mentioned", "recent":
456
+		return true
457
+	default:
458
+		return false
459
+	}
460
+}
461
+
462
+func normalizePullView(view string) string {
463
+	switch view {
464
+	case "assigned", "mentioned", "review-requests":
465
+		return view
466
+	default:
467
+		return "created"
468
+	}
469
+}
470
+
471
+func normalizeRepoView(view string) string {
472
+	switch view {
473
+	case "mine", "forks", "admin":
474
+		return view
475
+	default:
476
+		return "contributions"
477
+	}
478
+}
479
+
480
+func globalStateFromRequest(r *http.Request, rawQuery string) string {
481
+	state := normalizeGlobalState(r.URL.Query().Get("state"))
482
+	lower := strings.ToLower(rawQuery)
483
+	switch {
484
+	case strings.Contains(lower, "state:closed"), strings.Contains(lower, "is:closed"):
485
+		return "closed"
486
+	case strings.Contains(lower, "state:open"), strings.Contains(lower, "is:open"):
487
+		return "open"
488
+	default:
489
+		return state
490
+	}
491
+}
492
+
493
+func normalizeGlobalState(raw string) string {
494
+	switch raw {
495
+	case "closed", "all":
496
+		return raw
497
+	default:
498
+		return "open"
499
+	}
500
+}
501
+
502
+func globalIssueSearchText(raw string) string {
503
+	if raw == "" {
504
+		return ""
505
+	}
506
+	parts := strings.Fields(raw)
507
+	keep := make([]string, 0, len(parts))
508
+	for _, part := range parts {
509
+		token := strings.ToLower(strings.TrimSpace(part))
510
+		switch {
511
+		case token == "":
512
+			continue
513
+		case strings.HasPrefix(token, "is:"),
514
+			strings.HasPrefix(token, "state:"),
515
+			strings.HasPrefix(token, "archived:"),
516
+			strings.HasPrefix(token, "assignee:"),
517
+			strings.HasPrefix(token, "author:"),
518
+			strings.HasPrefix(token, "mentions:"),
519
+			strings.HasPrefix(token, "involves:"),
520
+			strings.HasPrefix(token, "review-requested:"),
521
+			strings.HasPrefix(token, "sort:"):
522
+			continue
523
+		default:
524
+			keep = append(keep, part)
525
+		}
526
+	}
527
+	return strings.Join(keep, " ")
528
+}
529
+
530
+func globalQueryWithoutStateOperators(raw string) string {
531
+	if raw == "" {
532
+		return ""
533
+	}
534
+	parts := strings.Fields(raw)
535
+	keep := make([]string, 0, len(parts))
536
+	for _, part := range parts {
537
+		token := strings.ToLower(strings.TrimSpace(part))
538
+		switch {
539
+		case token == "":
540
+			continue
541
+		case strings.HasPrefix(token, "state:"),
542
+			token == "is:open",
543
+			token == "is:closed":
544
+			continue
545
+		default:
546
+			keep = append(keep, part)
547
+		}
548
+	}
549
+	return strings.Join(keep, " ")
550
+}
551
+
552
+func issueViewLabel(view string) string {
553
+	switch view {
554
+	case "created":
555
+		return "Created by me"
556
+	case "mentioned":
557
+		return "Mentioned"
558
+	case "recent":
559
+		return "Recent activity"
560
+	default:
561
+		return "Assigned to me"
562
+	}
563
+}
564
+
565
+func repoViewLabel(view string) string {
566
+	switch view {
567
+	case "mine":
568
+		return "My repositories"
569
+	case "forks":
570
+		return "My forks"
571
+	case "admin":
572
+		return "Admin access"
573
+	default:
574
+		return "My contributions"
575
+	}
576
+}
577
+
578
+func defaultIssueQuery(view, state string) string {
579
+	switch view {
580
+	case "created":
581
+		return "is:issue state:" + state + " archived:false author:@me sort:updated-desc"
582
+	case "mentioned":
583
+		return "is:issue state:" + state + " archived:false mentions:@me sort:updated-desc"
584
+	case "recent":
585
+		return "is:issue state:" + state + " archived:false involves:@me sort:updated-desc"
586
+	default:
587
+		return "is:issue state:" + state + " archived:false assignee:@me sort:updated-desc"
588
+	}
589
+}
590
+
591
+func defaultPullQuery(view, state, username string) string {
592
+	if username == "" {
593
+		username = "@me"
594
+	} else {
595
+		username = "@" + username
596
+	}
597
+	switch view {
598
+	case "assigned":
599
+		return "is:pr state:" + state + " archived:false assignee:@me sort:updated-desc"
600
+	case "mentioned":
601
+		return "is:pr state:" + state + " archived:false mentions:@me sort:updated-desc"
602
+	case "review-requests":
603
+		return "is:pr state:" + state + " archived:false review-requested:@me sort:updated-desc"
604
+	default:
605
+		return "is:pr state:" + state + " archived:false author:" + username
606
+	}
607
+}
608
+
609
+func issueViewTabs(active, query, state string) []globalNavTab {
610
+	return []globalNavTab{
611
+		{Key: "assigned", Label: "Assigned to me", Icon: "people", Href: dashboardHref("/issues/assigned", queryValues(query, state, "")), Selected: active == "assigned"},
612
+		{Key: "created", Label: "Created by me", Icon: "smiley", Href: dashboardHref("/issues/created", queryValues(query, state, "")), Selected: active == "created"},
613
+		{Key: "mentioned", Label: "Mentioned", Icon: "mention", Href: dashboardHref("/issues/mentioned", queryValues(query, state, "")), Selected: active == "mentioned"},
614
+		{Key: "recent", Label: "Recent activity", Icon: "history", Href: dashboardHref("/issues/recent", queryValues(query, state, "")), Selected: active == "recent"},
615
+	}
616
+}
617
+
618
+func pullViewTabs(active, query, state string) []globalNavTab {
619
+	return []globalNavTab{
620
+		{Key: "created", Label: "Created", Href: dashboardHref("/pulls", queryValues(query, state, "created")), Selected: active == "created"},
621
+		{Key: "assigned", Label: "Assigned", Href: dashboardHref("/pulls", queryValues(query, state, "assigned")), Selected: active == "assigned"},
622
+		{Key: "mentioned", Label: "Mentioned", Href: dashboardHref("/pulls", queryValues(query, state, "mentioned")), Selected: active == "mentioned"},
623
+		{Key: "review-requests", Label: "Review requests", Href: dashboardHref("/pulls", queryValues(query, state, "review-requests")), Selected: active == "review-requests"},
624
+	}
625
+}
626
+
627
+func repoViewTabs(active, query string) []globalNavTab {
628
+	return []globalNavTab{
629
+		{Key: "contributions", Label: "My contributions", Icon: "people", Href: dashboardHref("/repos", repoQueryValues(query, "contributions")), Selected: active == "contributions"},
630
+		{Key: "mine", Label: "My repositories", Icon: "repo", Href: dashboardHref("/repos", repoQueryValues(query, "mine")), Selected: active == "mine"},
631
+		{Key: "forks", Label: "My forks", Icon: "repo-forked", Href: dashboardHref("/repos", repoQueryValues(query, "forks")), Selected: active == "forks"},
632
+		{Key: "admin", Label: "Admin access", Icon: "gear", Href: dashboardHref("/repos", repoQueryValues(query, "admin")), Selected: active == "admin"},
633
+	}
634
+}
635
+
636
+func stateTabs(path, query, active string, counts globalIssueCounts) []globalNavTab {
637
+	query = globalQueryWithoutStateOperators(query)
638
+	return []globalNavTab{
639
+		{Key: "open", Label: "Open", Icon: "issue-opened", Count: counts.Open, Href: dashboardHref(path, queryValues(query, "open", "")), Selected: active == "open"},
640
+		{Key: "closed", Label: "Closed", Icon: "check", Count: counts.Closed, Href: dashboardHref(path, queryValues(query, "closed", "")), Selected: active == "closed"},
641
+		{Key: "all", Label: "All", Icon: "list-unordered", Count: counts.Total, Href: dashboardHref(path, queryValues(query, "all", "")), Selected: active == "all"},
642
+	}
643
+}
644
+
645
+func queryValues(query, state, view string) url.Values {
646
+	values := url.Values{}
647
+	if query != "" {
648
+		values.Set("q", query)
649
+	}
650
+	if state != "" && state != "open" {
651
+		values.Set("state", state)
652
+	}
653
+	if view != "" && view != "created" {
654
+		values.Set("view", view)
655
+	}
656
+	return values
657
+}
658
+
659
+func repoQueryValues(query, view string) url.Values {
660
+	values := url.Values{}
661
+	if query != "" {
662
+		values.Set("q", query)
663
+	}
664
+	if view != "" && view != "contributions" {
665
+		values.Set("view", view)
666
+	}
667
+	return values
668
+}
669
+
670
+func dashboardHref(path string, values url.Values) string {
671
+	if encoded := values.Encode(); encoded != "" {
672
+		return path + "?" + encoded
673
+	}
674
+	return path
675
+}
676
+
677
+func countForState(counts globalIssueCounts, state string) int64 {
678
+	switch state {
679
+	case "closed":
680
+		return counts.Closed
681
+	case "all":
682
+		return counts.Total
683
+	default:
684
+		return counts.Open
685
+	}
686
+}
687
+
688
+func (h globalNavHandler) logError(ctx context.Context, msg string, err error) {
689
+	if h.logger != nil {
690
+		h.logger.ErrorContext(ctx, msg, "error", err)
691
+	}
692
+}
internal/web/handlers/global_dashboard_test.goadded
@@ -0,0 +1,58 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"net/http/httptest"
7
+	"testing"
8
+)
9
+
10
+func TestGlobalIssueSearchTextDropsDashboardOperators(t *testing.T) {
11
+	t.Parallel()
12
+
13
+	got := globalIssueSearchText("is:issue state:open archived:false assignee:@me sort:updated-desc flaky deploy")
14
+	if got != "flaky deploy" {
15
+		t.Fatalf("globalIssueSearchText = %q, want %q", got, "flaky deploy")
16
+	}
17
+}
18
+
19
+func TestStateTabsDropStaleQueryStateOperators(t *testing.T) {
20
+	t.Parallel()
21
+
22
+	tabs := stateTabs("/issues/assigned", "is:issue state:closed archived:false bug", "closed", globalIssueCounts{
23
+		Total:  3,
24
+		Open:   1,
25
+		Closed: 2,
26
+	})
27
+	if got := tabs[0].Href; got != "/issues/assigned?q=is%3Aissue+archived%3Afalse+bug" {
28
+		t.Fatalf("open state href = %q", got)
29
+	}
30
+	if got := tabs[1].Href; got != "/issues/assigned?q=is%3Aissue+archived%3Afalse+bug&state=closed" {
31
+		t.Fatalf("closed state href = %q", got)
32
+	}
33
+}
34
+
35
+func TestGlobalStateFromRequestHonorsQueryOperators(t *testing.T) {
36
+	t.Parallel()
37
+
38
+	req := httptest.NewRequest("GET", "/issues/assigned?state=open", nil)
39
+	if got := globalStateFromRequest(req, "is:issue state:closed archived:false"); got != "closed" {
40
+		t.Fatalf("globalStateFromRequest state:closed = %q, want closed", got)
41
+	}
42
+
43
+	req = httptest.NewRequest("GET", "/issues/assigned?state=all", nil)
44
+	if got := globalStateFromRequest(req, ""); got != "all" {
45
+		t.Fatalf("globalStateFromRequest query param = %q, want all", got)
46
+	}
47
+}
48
+
49
+func TestDashboardHrefOmitsEmptyQuery(t *testing.T) {
50
+	t.Parallel()
51
+
52
+	if got := dashboardHref("/pulls", queryValues("", "open", "created")); got != "/pulls" {
53
+		t.Fatalf("dashboardHref default values = %q, want /pulls", got)
54
+	}
55
+	if got := dashboardHref("/pulls", queryValues("state:closed bug", "closed", "assigned")); got != "/pulls?q=state%3Aclosed+bug&state=closed&view=assigned" {
56
+		t.Fatalf("dashboardHref populated values = %q", got)
57
+	}
58
+}
internal/web/handlers/handlers.gomodified
@@ -258,6 +258,15 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
258258
 		r.Get("/", helloHandler{render: rr, logoSVG: deps.LogoSVG, logger: deps.Logger}.ServeHTTP)
259259
 		r.Get("/explore", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeExplore)
260260
 		r.Get("/trending", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeTrending)
261
+		globalNavH := globalNavHandler{render: rr, logger: deps.Logger, pool: deps.Pool}
262
+		r.Group(func(r chi.Router) {
263
+			r.Use(middleware.RequireUser)
264
+			r.Get("/issues", globalNavH.RedirectIssues)
265
+			r.Get("/issues/new", globalNavH.ServeNewIssue)
266
+			r.Get("/issues/{view}", globalNavH.ServeIssues)
267
+			r.Get("/pulls", globalNavH.ServePulls)
268
+			r.Get("/repos", globalNavH.ServeRepos)
269
+		})
261270
 		// /internal/panic is a dev affordance: GET it to trigger the
262271
 		// panic-recovery path so an operator can confirm the styled 500
263272
 		// page renders. S35 will gate this behind a dev flag.
internal/web/render/octicons.gomodified
@@ -91,6 +91,8 @@ func BuiltinOcticons() OcticonResolver {
9191
 			`><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v6.5A1.75 1.75 0 0 1 13.25 11H8.06l-3.31 2.48A.75.75 0 0 1 3.5 12.88V11h-.75A1.75 1.75 0 0 1 1 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v6.5c0 .138.112.25.25.25h1.5a.75.75 0 0 1 .75.75v1.13l2.36-1.77a.75.75 0 0 1 .45-.15h5.44a.25.25 0 0 0 .25-.25v-6.5a.25.25 0 0 0-.25-.25Z"/></svg>`),
9292
 		"history": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
9393
 			`><path d="M1.643 3.143.427 1.927A.25.25 0 0 0 0 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 0 0 .177-.427L2.715 4.215A6.5 6.5 0 1 1 8 14.5a.75.75 0 0 0 0 1.5 8 8 0 1 0-6.357-12.857ZM7.25 4.75a.75.75 0 0 1 1.5 0v3.19l2.03 2.03a.75.75 0 1 1-1.06 1.06L7.47 8.78a.75.75 0 0 1-.22-.53Z"/></svg>`),
94
+		"light-bulb": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
95
+			`><path d="M8 1.5A4.5 4.5 0 0 0 5.5 9.739c.329.205.5.505.5.802V11a.75.75 0 0 0 1.5 0v-.459c0-.867-.514-1.61-1.204-2.04A3 3 0 1 1 9.5 8.5C8.875 8.89 8.5 9.573 8.5 10.25V11a.75.75 0 0 0 1.5 0v-.75c0-.12.072-.29.296-.429A4.5 4.5 0 0 0 8 1.5ZM6.75 13a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z"/></svg>`),
9496
 		"calendar": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
9597
 			`><path d="M4.75 0a.75.75 0 0 1 .75.75V2h5V.75a.75.75 0 0 1 1.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 0 1 4.75 0ZM2.5 7.5v6.75c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V7.5Zm10.75-4H2.75a.25.25 0 0 0-.25.25V6h11V3.75a.25.25 0 0 0-.25-.25Z"/></svg>`),
9698
 		"stopwatch": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
@@ -103,6 +105,8 @@ func BuiltinOcticons() OcticonResolver {
103105
 			`><path d="M8 1.5A2.5 2.5 0 0 0 5.5 4v2h6.75c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4V4a4 4 0 0 1 7.8-1.26.75.75 0 0 1-1.42.47A2.5 2.5 0 0 0 8 1.5ZM3.75 7.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25Z"/></svg>`),
104106
 		"tag": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
105107
 			`><path d="M1 2.75C1 1.784 1.784 1 2.75 1h4.586c.464 0 .909.184 1.237.513l5.914 5.914a1.75 1.75 0 0 1 0 2.475l-4.585 4.585a1.75 1.75 0 0 1-2.475 0L1.513 8.573A1.75 1.75 0 0 1 1 7.336Zm1.75-.25a.25.25 0 0 0-.25.25v4.586c0 .066.026.13.073.177l5.914 5.914a.25.25 0 0 0 .353 0l4.587-4.587a.25.25 0 0 0 0-.353L7.513 2.573a.25.25 0 0 0-.177-.073Zm2.25 4a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z"/></svg>`),
108
+		"mention": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
109
+			`><path d="M8 1.5a6.5 6.5 0 1 0 4.596 11.096.75.75 0 0 1 1.06 1.061A8 8 0 1 1 16 8v1.5A2.5 2.5 0 0 1 11.75 11.286 4 4 0 1 1 12 9.965V9.5A1.5 1.5 0 0 0 13.5 8 5.5 5.5 0 0 0 8 1.5Zm0 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z"/></svg>`),
106110
 		"person": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
107111
 			`><path d="M10.5 5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm1.5 0a4 4 0 1 0-8 0 4 4 0 0 0 8 0ZM2 14.25C2 11.35 4.686 9 8 9s6 2.35 6 5.25a.75.75 0 0 1-1.5 0c0-2.02-2.01-3.75-4.5-3.75s-4.5 1.73-4.5 3.75a.75.75 0 0 1-1.5 0Z"/></svg>`),
108112
 		"people": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
internal/web/static/css/shithub.cssmodified
@@ -196,10 +196,76 @@ code {
196196
   align-items: center;
197197
   flex-shrink: 0;
198198
 }
199
-.shithub-nav-new {
200
-  display: inline-flex;
199
+.shithub-nav-icon {
200
+  color: var(--fg-muted);
201
+}
202
+.shithub-nav-icon:hover {
203
+  color: var(--fg-default);
204
+}
205
+.shithub-nav-actions-divider {
206
+  width: 1px;
207
+  height: 24px;
208
+  background: var(--border-default);
209
+}
210
+.shithub-nav-action-menu {
211
+  position: relative;
212
+}
213
+.shithub-nav-action-menu > summary {
214
+  list-style: none;
215
+}
216
+.shithub-nav-action-menu > summary::-webkit-details-marker {
217
+  display: none;
218
+}
219
+.shithub-nav-create {
220
+  min-width: 48px;
221
+  height: 32px;
222
+  padding: 0 0.5rem;
223
+  gap: 0.1rem;
224
+}
225
+.shithub-nav-action-panel {
226
+  position: absolute;
227
+  right: 0;
228
+  top: calc(100% + 0.45rem);
229
+  z-index: 60;
230
+  display: grid;
231
+  min-width: 220px;
232
+  padding: 0.4rem;
233
+  border: 1px solid var(--border-default);
234
+  border-radius: 8px;
235
+  background: var(--canvas-default);
236
+  box-shadow: 0 12px 28px rgba(1, 4, 9, 0.36);
237
+}
238
+.shithub-nav-action-item {
239
+  display: grid;
240
+  grid-template-columns: 16px minmax(0, 1fr);
241
+  gap: 0.65rem;
201242
   align-items: center;
202
-  gap: 0.35rem;
243
+  min-height: 34px;
244
+  padding: 0.4rem 0.5rem;
245
+  border-radius: 6px;
246
+  color: var(--fg-default);
247
+  font-size: 0.875rem;
248
+  font-weight: 500;
249
+  text-decoration: none;
250
+}
251
+.shithub-nav-action-item svg {
252
+  color: var(--fg-muted);
253
+}
254
+.shithub-nav-action-item:hover {
255
+  background: var(--canvas-subtle);
256
+  text-decoration: none;
257
+}
258
+.shithub-nav-action-item.is-disabled {
259
+  color: var(--fg-muted);
260
+  cursor: default;
261
+}
262
+.shithub-nav-action-item.is-disabled:hover {
263
+  background: transparent;
264
+}
265
+.shithub-nav-action-divider {
266
+  height: 1px;
267
+  margin: 0.35rem -0.4rem;
268
+  background: var(--border-default);
203269
 }
204270
 .shithub-nav-local {
205271
   padding: 0 1rem;
@@ -8971,6 +9037,451 @@ button.shithub-repo-action {
89719037
 .shithub-imp-write { background: #ffd33d; color: #1a1f24; padding: 0 0.4em; border-radius: 4px; font-weight: 600; }
89729038
 .shithub-imp-read  { background: rgba(255, 255, 255, 0.2); padding: 0 0.4em; border-radius: 4px; }
89739039
 
9040
+/* Global dashboard surfaces: /issues, /pulls, /repos. */
9041
+.shithub-global-page {
9042
+  width: 100%;
9043
+  padding: 1.5rem 1rem 3rem;
9044
+}
9045
+.shithub-global-shell {
9046
+  display: grid;
9047
+  grid-template-columns: 240px minmax(0, 1fr);
9048
+  gap: 2rem;
9049
+  max-width: 1280px;
9050
+  margin: 0 auto;
9051
+}
9052
+.shithub-global-shell-wide {
9053
+  max-width: 1440px;
9054
+}
9055
+.shithub-global-centered {
9056
+  max-width: 1080px;
9057
+  margin: 0 auto;
9058
+}
9059
+.shithub-global-sidebar {
9060
+  position: sticky;
9061
+  top: 1rem;
9062
+  align-self: start;
9063
+  min-width: 0;
9064
+}
9065
+.shithub-global-side-nav {
9066
+  display: grid;
9067
+  gap: 0.15rem;
9068
+  padding-bottom: 1.25rem;
9069
+}
9070
+.shithub-global-side-link {
9071
+  position: relative;
9072
+  display: flex;
9073
+  align-items: center;
9074
+  gap: 0.6rem;
9075
+  min-height: 36px;
9076
+  padding: 0.45rem 0.75rem;
9077
+  border-radius: 6px;
9078
+  color: var(--fg-default);
9079
+  font-weight: 600;
9080
+}
9081
+.shithub-global-side-link svg {
9082
+  color: var(--fg-muted);
9083
+}
9084
+.shithub-global-side-link:hover {
9085
+  background: var(--canvas-subtle);
9086
+  text-decoration: none;
9087
+}
9088
+.shithub-global-side-link.is-selected {
9089
+  background: var(--canvas-subtle);
9090
+}
9091
+.shithub-global-side-link.is-selected::before {
9092
+  content: "";
9093
+  position: absolute;
9094
+  left: 0;
9095
+  top: 6px;
9096
+  bottom: 6px;
9097
+  width: 4px;
9098
+  border-radius: 999px;
9099
+  background: var(--accent-emphasis);
9100
+}
9101
+.shithub-global-side-section {
9102
+  display: grid;
9103
+  grid-template-columns: minmax(0, 1fr) auto;
9104
+  gap: 0.4rem;
9105
+  padding: 1.25rem 0.75rem 0;
9106
+  border-top: 1px solid var(--border-default);
9107
+}
9108
+.shithub-global-side-section h2 {
9109
+  margin: 0;
9110
+  color: var(--fg-muted);
9111
+  font-size: 0.8rem;
9112
+}
9113
+.shithub-global-side-section p {
9114
+  grid-column: 1 / -1;
9115
+  margin: 0;
9116
+  color: var(--fg-muted);
9117
+  font-size: 0.8rem;
9118
+}
9119
+.shithub-global-side-add {
9120
+  display: inline-flex;
9121
+  align-items: center;
9122
+  justify-content: center;
9123
+  width: 24px;
9124
+  height: 24px;
9125
+  padding: 0;
9126
+  border: 0;
9127
+  background: transparent;
9128
+  color: var(--fg-muted);
9129
+}
9130
+.shithub-global-main {
9131
+  min-width: 0;
9132
+}
9133
+.shithub-global-head {
9134
+  display: flex;
9135
+  align-items: center;
9136
+  justify-content: space-between;
9137
+  gap: 1rem;
9138
+  margin-bottom: 0.85rem;
9139
+}
9140
+.shithub-global-head-compact {
9141
+  margin-bottom: 0.75rem;
9142
+}
9143
+.shithub-global-head h1 {
9144
+  margin: 0;
9145
+  font-size: 1.5rem;
9146
+  line-height: 1.25;
9147
+}
9148
+.shithub-global-query {
9149
+  display: flex;
9150
+  align-items: stretch;
9151
+  width: 100%;
9152
+  margin: 0 0 1rem;
9153
+}
9154
+.shithub-global-query-inline {
9155
+  flex: 1 1 28rem;
9156
+  margin: 0;
9157
+}
9158
+.shithub-global-query input[type="text"] {
9159
+  flex: 1;
9160
+  min-width: 0;
9161
+  min-height: 36px;
9162
+  padding: 0.45rem 0.85rem;
9163
+  border-radius: 6px 0 0 6px;
9164
+}
9165
+.shithub-global-query button {
9166
+  display: inline-flex;
9167
+  align-items: center;
9168
+  justify-content: center;
9169
+  width: 44px;
9170
+  border: 1px solid var(--border-default);
9171
+  border-left: 0;
9172
+  border-radius: 0 6px 6px 0;
9173
+  color: var(--fg-muted);
9174
+  background: var(--button-default-bg);
9175
+  cursor: pointer;
9176
+}
9177
+.shithub-global-query button:hover {
9178
+  color: var(--fg-default);
9179
+  background: var(--button-default-hover-bg);
9180
+}
9181
+.shithub-global-toolbar-row {
9182
+  display: flex;
9183
+  align-items: stretch;
9184
+  gap: 0.75rem;
9185
+  margin-bottom: 1rem;
9186
+}
9187
+.shithub-global-top-tabs {
9188
+  display: inline-flex;
9189
+  flex: 0 0 auto;
9190
+  border: 1px solid var(--border-default);
9191
+  border-radius: 6px;
9192
+  overflow: hidden;
9193
+}
9194
+.shithub-global-top-tabs a {
9195
+  display: inline-flex;
9196
+  align-items: center;
9197
+  min-height: 36px;
9198
+  padding: 0.45rem 0.9rem;
9199
+  border-left: 1px solid var(--border-default);
9200
+  color: var(--fg-default);
9201
+  font-weight: 600;
9202
+}
9203
+.shithub-global-top-tabs a:first-child {
9204
+  border-left: 0;
9205
+}
9206
+.shithub-global-top-tabs a:hover {
9207
+  background: var(--canvas-subtle);
9208
+  text-decoration: none;
9209
+}
9210
+.shithub-global-top-tabs a.is-selected {
9211
+  background: var(--accent-emphasis);
9212
+  color: #fff;
9213
+}
9214
+.shithub-global-panel {
9215
+  border: 1px solid var(--border-default);
9216
+  border-radius: 6px;
9217
+  overflow: hidden;
9218
+  background: var(--canvas-default);
9219
+}
9220
+.shithub-global-panel-head {
9221
+  display: flex;
9222
+  align-items: center;
9223
+  justify-content: space-between;
9224
+  gap: 1rem;
9225
+  min-height: 52px;
9226
+  padding: 0.75rem 1rem;
9227
+  border-bottom: 1px solid var(--border-default);
9228
+  background: var(--canvas-subtle);
9229
+}
9230
+.shithub-global-state-tabs,
9231
+.shithub-global-filter-set {
9232
+  display: inline-flex;
9233
+  align-items: center;
9234
+  gap: 0.75rem;
9235
+  flex-wrap: wrap;
9236
+}
9237
+.shithub-global-state-tabs a {
9238
+  display: inline-flex;
9239
+  align-items: center;
9240
+  gap: 0.35rem;
9241
+  color: var(--fg-muted);
9242
+  font-weight: 600;
9243
+}
9244
+.shithub-global-state-tabs a.is-selected,
9245
+.shithub-global-state-tabs a:hover {
9246
+  color: var(--fg-default);
9247
+  text-decoration: none;
9248
+}
9249
+.shithub-global-filter-set button,
9250
+.shithub-global-sort summary {
9251
+  display: inline-flex;
9252
+  align-items: center;
9253
+  gap: 0.35rem;
9254
+  border: 0;
9255
+  background: transparent;
9256
+  color: var(--fg-muted);
9257
+  font: inherit;
9258
+  font-weight: 600;
9259
+}
9260
+.shithub-global-filter-set button {
9261
+  cursor: default;
9262
+}
9263
+.shithub-global-sort {
9264
+  position: relative;
9265
+}
9266
+.shithub-global-sort summary {
9267
+  list-style: none;
9268
+  cursor: pointer;
9269
+}
9270
+.shithub-global-sort summary::-webkit-details-marker {
9271
+  display: none;
9272
+}
9273
+.shithub-global-sort div {
9274
+  position: absolute;
9275
+  right: 0;
9276
+  top: calc(100% + 0.4rem);
9277
+  min-width: 160px;
9278
+  padding: 0.45rem;
9279
+  border: 1px solid var(--border-default);
9280
+  border-radius: 6px;
9281
+  background: var(--canvas-default);
9282
+  box-shadow: 0 10px 24px rgba(1, 4, 9, 0.32);
9283
+}
9284
+.shithub-global-sort span {
9285
+  display: block;
9286
+  padding: 0.35rem 0.45rem;
9287
+  color: var(--fg-default);
9288
+  font-size: 0.875rem;
9289
+}
9290
+.shithub-global-thread-list,
9291
+.shithub-global-repo-list {
9292
+  margin: 0;
9293
+  padding: 0;
9294
+  list-style: none;
9295
+}
9296
+.shithub-global-thread-row {
9297
+  display: grid;
9298
+  grid-template-columns: 20px minmax(0, 1fr) auto;
9299
+  gap: 0.85rem;
9300
+  align-items: start;
9301
+  padding: 1rem;
9302
+  border-top: 1px solid var(--border-default);
9303
+}
9304
+.shithub-global-thread-row:first-child,
9305
+.shithub-global-repo-row:first-child {
9306
+  border-top: 0;
9307
+}
9308
+.shithub-global-thread-state {
9309
+  display: inline-flex;
9310
+  align-items: center;
9311
+  justify-content: center;
9312
+  width: 20px;
9313
+  height: 20px;
9314
+  margin-top: 0.15rem;
9315
+}
9316
+.shithub-global-thread-state-open svg {
9317
+  color: var(--success-fg);
9318
+}
9319
+.shithub-global-thread-state-closed svg,
9320
+.shithub-global-thread-state-pr svg {
9321
+  color: var(--accent-fg);
9322
+}
9323
+.shithub-global-thread-main {
9324
+  min-width: 0;
9325
+}
9326
+.shithub-global-thread-title {
9327
+  color: var(--fg-default);
9328
+  font-size: 1rem;
9329
+  font-weight: 700;
9330
+}
9331
+.shithub-global-thread-title span {
9332
+  color: var(--fg-muted);
9333
+}
9334
+.shithub-global-thread-title:hover {
9335
+  color: var(--accent-fg);
9336
+  text-decoration: none;
9337
+}
9338
+.shithub-global-thread-main p {
9339
+  margin: 0.2rem 0 0;
9340
+  color: var(--fg-muted);
9341
+  font-size: 0.85rem;
9342
+}
9343
+.shithub-global-thread-comments {
9344
+  display: inline-flex;
9345
+  align-items: center;
9346
+  gap: 0.3rem;
9347
+  color: var(--fg-muted);
9348
+  font-size: 0.85rem;
9349
+}
9350
+.shithub-global-repo-row {
9351
+  display: grid;
9352
+  grid-template-columns: minmax(0, 1fr) 140px;
9353
+  gap: 1rem;
9354
+  align-items: center;
9355
+  padding: 1rem;
9356
+  border-top: 1px solid var(--border-default);
9357
+}
9358
+.shithub-global-repo-row-action {
9359
+  grid-template-columns: minmax(0, 1fr) auto;
9360
+}
9361
+.shithub-global-repo-main {
9362
+  min-width: 0;
9363
+}
9364
+.shithub-global-repo-main h2 {
9365
+  display: flex;
9366
+  align-items: center;
9367
+  gap: 0.45rem;
9368
+  flex-wrap: wrap;
9369
+  margin: 0;
9370
+  font-size: 1rem;
9371
+}
9372
+.shithub-global-repo-main h2 a {
9373
+  color: var(--fg-default);
9374
+}
9375
+.shithub-global-repo-main h2 a:hover {
9376
+  color: var(--accent-fg);
9377
+  text-decoration: none;
9378
+}
9379
+.shithub-global-repo-main p {
9380
+  margin: 0.35rem 0 0;
9381
+  color: var(--fg-muted);
9382
+}
9383
+.shithub-global-repo-meta {
9384
+  display: flex;
9385
+  flex-wrap: wrap;
9386
+  gap: 0.45rem 0.7rem;
9387
+  margin: 0.55rem 0 0;
9388
+  padding: 0;
9389
+  color: var(--fg-muted);
9390
+  list-style: none;
9391
+  font-size: 0.82rem;
9392
+}
9393
+.shithub-global-repo-meta li {
9394
+  display: inline-flex;
9395
+  align-items: center;
9396
+  gap: 0.25rem;
9397
+}
9398
+.shithub-global-sparkline {
9399
+  justify-self: end;
9400
+  width: 112px;
9401
+  height: 28px;
9402
+  border-bottom: 1px solid rgba(63, 185, 80, 0.65);
9403
+  background:
9404
+    linear-gradient(90deg, transparent 0 78%, rgba(63, 185, 80, 0.45) 78% 80%, transparent 80% 88%, rgba(63, 185, 80, 0.7) 88% 90%, transparent 90%),
9405
+    linear-gradient(180deg, transparent 0 58%, rgba(63, 185, 80, 0.2) 58% 61%, transparent 61%);
9406
+}
9407
+.shithub-global-empty {
9408
+  padding: 2.25rem 1rem;
9409
+  text-align: center;
9410
+}
9411
+.shithub-global-empty h2 {
9412
+  margin: 0;
9413
+  font-size: 1rem;
9414
+}
9415
+.shithub-global-empty p,
9416
+.shithub-global-muted {
9417
+  margin: 0.35rem 0 0;
9418
+  color: var(--fg-muted);
9419
+}
9420
+.shithub-global-protip {
9421
+  margin: 1.25rem 0 0;
9422
+  color: var(--fg-muted);
9423
+  text-align: center;
9424
+}
9425
+@media (max-width: 1080px) {
9426
+  .shithub-nav-actions {
9427
+    gap: 0.35rem;
9428
+  }
9429
+  .shithub-nav-actions-divider,
9430
+  .shithub-nav-actions > a[aria-label="Your organizations"] {
9431
+    display: none;
9432
+  }
9433
+}
9434
+@media (max-width: 900px) {
9435
+  .shithub-global-shell,
9436
+  .shithub-global-shell-wide {
9437
+    grid-template-columns: 1fr;
9438
+    gap: 1rem;
9439
+  }
9440
+  .shithub-global-sidebar {
9441
+    position: static;
9442
+  }
9443
+  .shithub-global-side-nav {
9444
+    display: flex;
9445
+    overflow-x: auto;
9446
+    padding-bottom: 0.5rem;
9447
+  }
9448
+  .shithub-global-side-link {
9449
+    flex: 0 0 auto;
9450
+  }
9451
+  .shithub-global-side-section {
9452
+    display: none;
9453
+  }
9454
+  .shithub-global-toolbar-row {
9455
+    flex-direction: column;
9456
+  }
9457
+  .shithub-global-top-tabs {
9458
+    overflow-x: auto;
9459
+  }
9460
+}
9461
+@media (max-width: 640px) {
9462
+  .shithub-nav-actions > a[aria-label="All issues"],
9463
+  .shithub-nav-actions > a[aria-label="All pull requests"] {
9464
+    display: none;
9465
+  }
9466
+  .shithub-global-page {
9467
+    padding: 1rem 0.75rem 2rem;
9468
+  }
9469
+  .shithub-global-head,
9470
+  .shithub-global-panel-head {
9471
+    align-items: flex-start;
9472
+    flex-direction: column;
9473
+  }
9474
+  .shithub-global-thread-row,
9475
+  .shithub-global-repo-row,
9476
+  .shithub-global-repo-row-action {
9477
+    grid-template-columns: 1fr;
9478
+  }
9479
+  .shithub-global-thread-state,
9480
+  .shithub-global-sparkline {
9481
+    display: none;
9482
+  }
9483
+}
9484
+
89749485
 /* S42 — social dashboard and Explore feed. */
89759486
 .shithub-dashboard-page,
89769487
 .shithub-explore-page {
internal/web/templates/dashboard/issues.htmladded
@@ -0,0 +1,70 @@
1
+{{ define "page" -}}
2
+<section class="shithub-global-page">
3
+  <div class="shithub-global-shell">
4
+    <aside class="shithub-global-sidebar" aria-label="Issue views">
5
+      <nav class="shithub-global-side-nav">
6
+        {{ range .Views }}
7
+        <a href="{{ .Href }}" class="shithub-global-side-link{{ if .Selected }} is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>
8
+          {{ octicon .Icon }} <span>{{ .Label }}</span>
9
+        </a>
10
+        {{ end }}
11
+      </nav>
12
+      <div class="shithub-global-side-section">
13
+        <h2>Views</h2>
14
+        <button type="button" class="shithub-global-side-add" disabled aria-label="Create saved view">{{ octicon "plus" }}</button>
15
+        <p>No saved views</p>
16
+      </div>
17
+    </aside>
18
+
19
+    <main class="shithub-global-main">
20
+      <header class="shithub-global-head">
21
+        <h1>{{ .Heading }}</h1>
22
+        <a href="{{ .NewHref }}" class="shithub-button shithub-button-primary">New issue</a>
23
+      </header>
24
+
25
+      <form action="" method="get" class="shithub-global-query">
26
+        <label class="sr-only" for="global-issues-query">Search issues</label>
27
+        <input id="global-issues-query" type="text" name="q" value="{{ .Query }}" autocomplete="off">
28
+        <button type="submit" aria-label="Search issues">{{ octicon "search" }}</button>
29
+      </form>
30
+
31
+      <section class="shithub-global-panel" aria-label="Issues">
32
+        <div class="shithub-global-panel-head">
33
+          <strong>{{ .ResultCount }} results</strong>
34
+          <nav class="shithub-global-state-tabs" aria-label="Issue state">
35
+            {{ range .StateTabs }}
36
+            <a href="{{ .Href }}" class="{{ if .Selected }}is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>{{ octicon .Icon }} {{ .Count }} {{ .Label }}</a>
37
+            {{ end }}
38
+          </nav>
39
+          <details class="shithub-global-sort">
40
+            <summary>{{ octicon "list-unordered" }} Updated</summary>
41
+            <div role="menu"><span role="menuitem">Newest updated</span></div>
42
+          </details>
43
+        </div>
44
+
45
+        {{ if .Issues }}
46
+        <ol class="shithub-global-thread-list">
47
+          {{ range .Issues }}
48
+          <li class="shithub-global-thread-row">
49
+            <span class="shithub-global-thread-state shithub-global-thread-state-{{ .State }}">{{ if eq .State "closed" }}{{ octicon "issue-closed" }}{{ else }}{{ octicon "issue-opened" }}{{ end }}</span>
50
+            <div class="shithub-global-thread-main">
51
+              <a class="shithub-global-thread-title" href="{{ .URL }}">{{ .Title }}</a>
52
+              <p><a href="{{ .RepoURL }}">{{ .Owner }}/{{ .RepoName }}</a>#{{ .Number }} opened by {{ if .AuthorName }}{{ .AuthorName }}{{ else }}ghost{{ end }} · Updated {{ relativeTime .UpdatedAt }}</p>
53
+            </div>
54
+            {{ if gt .CommentCount 0 }}
55
+            <a class="shithub-global-thread-comments" href="{{ .URL }}">{{ octicon "comment" }} {{ .CommentCount }}</a>
56
+            {{ end }}
57
+          </li>
58
+          {{ end }}
59
+        </ol>
60
+        {{ else }}
61
+        <div class="shithub-global-empty">
62
+          <h2>{{ .EmptyTitle }}</h2>
63
+          <p>{{ .EmptyMessage }}</p>
64
+        </div>
65
+        {{ end }}
66
+      </section>
67
+    </main>
68
+  </div>
69
+</section>
70
+{{- end }}
internal/web/templates/dashboard/new_issue.htmladded
@@ -0,0 +1,42 @@
1
+{{ define "page" -}}
2
+<section class="shithub-global-page">
3
+  <div class="shithub-global-centered">
4
+    <main class="shithub-global-main">
5
+      <header class="shithub-global-head">
6
+        <h1>{{ .Heading }}</h1>
7
+      </header>
8
+
9
+      <form action="/issues/new" method="get" class="shithub-global-query">
10
+        <label class="sr-only" for="global-new-issue-query">Find a repository</label>
11
+        <input id="global-new-issue-query" type="text" name="q" value="{{ .Query }}" placeholder="Find a repository..." autocomplete="off">
12
+        <button type="submit" aria-label="Search repositories">{{ octicon "search" }}</button>
13
+      </form>
14
+
15
+      <section class="shithub-global-panel" aria-label="Choose repository">
16
+        <div class="shithub-global-panel-head">
17
+          <strong>Choose a repository</strong>
18
+          <span class="shithub-global-muted">{{ .TotalCount }} available</span>
19
+        </div>
20
+        {{ if .Repos }}
21
+        <ol class="shithub-global-repo-list">
22
+          {{ range .Repos }}
23
+          <li class="shithub-global-repo-row shithub-global-repo-row-action">
24
+            <div class="shithub-global-repo-main">
25
+              <h2><a href="{{ .URL }}">{{ .Owner }}/{{ .Name }}</a> {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }}</h2>
26
+              {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
27
+            </div>
28
+            <a class="shithub-button" href="{{ .URL }}/issues/new">New issue</a>
29
+          </li>
30
+          {{ end }}
31
+        </ol>
32
+        {{ else }}
33
+        <div class="shithub-global-empty">
34
+          <h2>{{ .EmptyTitle }}</h2>
35
+          <p>{{ .EmptyBody }}</p>
36
+        </div>
37
+        {{ end }}
38
+      </section>
39
+    </main>
40
+  </div>
41
+</section>
42
+{{- end }}
internal/web/templates/dashboard/pulls.htmladded
@@ -0,0 +1,64 @@
1
+{{ define "page" -}}
2
+<section class="shithub-global-page">
3
+  <div class="shithub-global-centered">
4
+    <main class="shithub-global-main">
5
+      <header class="shithub-global-head shithub-global-head-compact">
6
+        <h1>{{ .Heading }}</h1>
7
+      </header>
8
+
9
+      <div class="shithub-global-toolbar-row">
10
+        <nav class="shithub-global-top-tabs" aria-label="Pull request views">
11
+          {{ range .Views }}
12
+          <a href="{{ .Href }}" class="{{ if .Selected }}is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>{{ .Label }}</a>
13
+          {{ end }}
14
+        </nav>
15
+        <form action="/pulls" method="get" class="shithub-global-query shithub-global-query-inline">
16
+          {{ range .Views }}{{ if .Selected }}{{ if ne .Key "created" }}<input type="hidden" name="view" value="{{ .Key }}">{{ end }}{{ end }}{{ end }}
17
+          <label class="sr-only" for="global-pulls-query">Search pull requests</label>
18
+          <input id="global-pulls-query" type="text" name="q" value="{{ .Query }}" autocomplete="off">
19
+          <button type="submit" aria-label="Search pull requests">{{ octicon "search" }}</button>
20
+        </form>
21
+      </div>
22
+
23
+      <section class="shithub-global-panel" aria-label="Pull requests">
24
+        <div class="shithub-global-panel-head">
25
+          <nav class="shithub-global-state-tabs" aria-label="Pull request state">
26
+            {{ range .StateTabs }}
27
+            <a href="{{ .Href }}" class="{{ if .Selected }}is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>{{ octicon .Icon }} {{ .Count }} {{ .Label }}</a>
28
+            {{ end }}
29
+          </nav>
30
+          <div class="shithub-global-filter-set" aria-label="Pull request filters">
31
+            <button type="button" disabled>Visibility {{ octicon "triangle-down" }}</button>
32
+            <button type="button" disabled>Organization {{ octicon "triangle-down" }}</button>
33
+            <button type="button" disabled>Sort {{ octicon "triangle-down" }}</button>
34
+          </div>
35
+        </div>
36
+
37
+        {{ if .Pulls }}
38
+        <ol class="shithub-global-thread-list">
39
+          {{ range .Pulls }}
40
+          <li class="shithub-global-thread-row">
41
+            <span class="shithub-global-thread-state shithub-global-thread-state-pr">{{ octicon "git-pull-request" }}</span>
42
+            <div class="shithub-global-thread-main">
43
+              <a class="shithub-global-thread-title" href="{{ .URL }}"><span>{{ .Owner }}/{{ .RepoName }}</span> {{ .Title }}</a>
44
+              <p>#{{ .Number }} opened by {{ if .AuthorName }}{{ .AuthorName }}{{ else }}ghost{{ end }} · Updated {{ relativeTime .UpdatedAt }}</p>
45
+            </div>
46
+            {{ if gt .CommentCount 0 }}
47
+            <a class="shithub-global-thread-comments" href="{{ .URL }}">{{ octicon "comment" }} {{ .CommentCount }}</a>
48
+            {{ end }}
49
+          </li>
50
+          {{ end }}
51
+        </ol>
52
+        {{ else }}
53
+        <div class="shithub-global-empty">
54
+          <h2>{{ .EmptyTitle }}</h2>
55
+          <p>Try another pull request view or search term.</p>
56
+        </div>
57
+        {{ end }}
58
+      </section>
59
+
60
+      <p class="shithub-global-protip">{{ octicon "light-bulb" }} <strong>ProTip!</strong> Mention someone in a pull request with @{{ .Viewer.Username }}.</p>
61
+    </main>
62
+  </div>
63
+</section>
64
+{{- end }}
internal/web/templates/dashboard/repos.htmladded
@@ -0,0 +1,72 @@
1
+{{ define "page" -}}
2
+<section class="shithub-global-page">
3
+  <div class="shithub-global-shell shithub-global-shell-wide">
4
+    <aside class="shithub-global-sidebar" aria-label="Repository views">
5
+      <nav class="shithub-global-side-nav">
6
+        {{ range .Views }}
7
+        <a href="{{ .Href }}" class="shithub-global-side-link{{ if .Selected }} is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>
8
+          {{ octicon .Icon }} <span>{{ .Label }}</span>
9
+        </a>
10
+        {{ end }}
11
+      </nav>
12
+      <div class="shithub-global-side-section">
13
+        <h2>Views</h2>
14
+        <button type="button" class="shithub-global-side-add" disabled aria-label="Create saved view">{{ octicon "plus" }}</button>
15
+        <p>No saved views</p>
16
+      </div>
17
+    </aside>
18
+
19
+    <main class="shithub-global-main">
20
+      <header class="shithub-global-head">
21
+        <h1>{{ .Heading }}</h1>
22
+        <a href="{{ .NewRepoHref }}" class="shithub-button shithub-button-primary">New repository</a>
23
+      </header>
24
+
25
+      <form action="/repos" method="get" class="shithub-global-query">
26
+        {{ range .Views }}{{ if .Selected }}{{ if ne .Key "contributions" }}<input type="hidden" name="view" value="{{ .Key }}">{{ end }}{{ end }}{{ end }}
27
+        <label class="sr-only" for="global-repos-query">Search repositories</label>
28
+        <input id="global-repos-query" type="text" name="q" value="{{ .Query }}" placeholder="Find a repository..." autocomplete="off">
29
+        <button type="submit" aria-label="Search repositories">{{ octicon "search" }}</button>
30
+      </form>
31
+
32
+      <section class="shithub-global-panel" aria-label="Repositories">
33
+        <div class="shithub-global-panel-head">
34
+          <strong>{{ .TotalCount }} repositories</strong>
35
+          <div class="shithub-global-filter-set" aria-label="Repository layout">
36
+            <button type="button" disabled>{{ octicon "list-unordered" }} Relevance {{ octicon "triangle-down" }}</button>
37
+            <button type="button" disabled aria-label="List view">{{ octicon "list-unordered" }}</button>
38
+          </div>
39
+        </div>
40
+
41
+        {{ if .Repos }}
42
+        <ol class="shithub-global-repo-list">
43
+          {{ range .Repos }}
44
+          <li class="shithub-global-repo-row">
45
+            <div class="shithub-global-repo-main">
46
+              <h2><a href="{{ .URL }}">{{ .Owner }}/{{ .Name }}</a> {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }}</h2>
47
+              {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
48
+              <ul class="shithub-global-repo-meta">
49
+                {{ if .PrimaryLanguage }}<li><span class="shithub-lang-dot" aria-hidden="true"></span>{{ .PrimaryLanguage }}</li>{{ end }}
50
+                {{ if .LicenseKey }}<li>{{ octicon "law" }} {{ .LicenseKey }}</li>{{ end }}
51
+                <li>{{ octicon "repo-forked" }} {{ .ForkCount }}</li>
52
+                <li>{{ octicon "star" }} {{ .StarCount }}</li>
53
+                <li>{{ octicon "eye" }} {{ .WatcherCount }}</li>
54
+                {{ if .IsFork }}<li>Fork</li>{{ end }}
55
+                <li>Updated {{ relativeTime .UpdatedAt }}</li>
56
+              </ul>
57
+            </div>
58
+            <span class="shithub-global-sparkline" aria-hidden="true"></span>
59
+          </li>
60
+          {{ end }}
61
+        </ol>
62
+        {{ else }}
63
+        <div class="shithub-global-empty">
64
+          <h2>{{ .EmptyTitle }}</h2>
65
+          <p>{{ .EmptyBody }}</p>
66
+        </div>
67
+        {{ end }}
68
+      </section>
69
+    </main>
70
+  </div>
71
+</section>
72
+{{- end }}