tenseleyflow/shithub / 462ee9e

Browse files

Align notifications inbox UI

Authored by espadonne
SHA
462ee9ee7de241dcb405ea9786f300b4bb323164
Parents
f305fee
Tree
ab9fbea

5 changed files

StatusFile+-
M docs/internal/notifications.md 10 0
M internal/web/handlers/notifications/notifications.go 201 5
A internal/web/handlers/notifications/notifications_test.go 113 0
M internal/web/static/css/shithub.css 329 0
M internal/web/templates/notifications/inbox.html 93 51
docs/internal/notifications.mdmodified
@@ -145,6 +145,16 @@ must set the explicit key in prod.
145145
 | POST  | `/threads/{kind}/{id}/unsubscribe`      | required | Per-thread unsubscribe override    |
146146
 | GET   | `/notifications/unsubscribe`            | none     | One-click HMAC-signed unsub        |
147147
 
148
+## Inbox UI
149
+
150
+The web inbox mirrors GitHub's two-column notification shape for the
151
+states shithub actually stores: a left filter rail for Inbox and
152
+Unread, a main toolbar, row-level issue / pull request icons, unread
153
+indicators, and icon buttons for marking one row read or unread.
154
+Read-state forms include a `return_to` value restricted to
155
+`/notifications` so filtered and paged inbox views stay put after the
156
+POST without introducing an open redirect.
157
+
148158
 ## What we deferred from the spec
149159
 
150160
 * **API endpoint** `GET /api/v1/notifications` + the per-thread sub
internal/web/handlers/notifications/notifications.gomodified
@@ -15,7 +15,10 @@ import (
1515
 	"errors"
1616
 	"log/slog"
1717
 	"net/http"
18
+	"net/url"
1819
 	"strconv"
20
+	"strings"
21
+	"time"
1922
 
2023
 	"github.com/go-chi/chi/v5"
2124
 	"github.com/jackc/pgx/v5/pgxpool"
@@ -81,7 +84,8 @@ func (h *Handlers) list(w http.ResponseWriter, r *http.Request) {
8184
 		return
8285
 	}
8386
 	page := pageFromRequest(r)
84
-	onlyUnread := r.URL.Query().Get("filter") == "unread"
87
+	filter := filterFromRequest(r)
88
+	onlyUnread := filter == "unread"
8589
 
8690
 	q := notifdb.New()
8791
 	rows, err := q.ListNotificationsForRecipient(r.Context(), h.d.Pool, notifdb.ListNotificationsForRecipientParams{
@@ -96,15 +100,25 @@ func (h *Handlers) list(w http.ResponseWriter, r *http.Request) {
96100
 		return
97101
 	}
98102
 	unreadCount, _ := q.CountUnreadForRecipient(r.Context(), h.d.Pool, viewer.ID)
103
+	allCount, _ := q.CountNotificationsForRecipient(r.Context(), h.d.Pool, notifdb.CountNotificationsForRecipientParams{
104
+		RecipientUserID: viewer.ID,
105
+		Column2:         false,
106
+	})
99107
 
100108
 	data := map[string]any{
101109
 		"Title":         "Notifications",
102
-		"Notifications": rows,
110
+		"Notifications": notificationInboxItems(rows),
111
+		"AllCount":      allCount,
103112
 		"UnreadCount":   unreadCount,
104
-		"Filter":        r.URL.Query().Get("filter"),
113
+		"Filter":        filter,
114
+		"AllHref":       notificationsPageHref("", 1),
115
+		"UnreadHref":    notificationsPageHref("unread", 1),
116
+		"CurrentURL":    r.URL.RequestURI(),
105117
 		"Page":          page,
106118
 		"HasPrev":       page > 1,
107119
 		"HasNext":       len(rows) == pageSize,
120
+		"PrevHref":      notificationsPageHref(filter, page-1),
121
+		"NextHref":      notificationsPageHref(filter, page+1),
108122
 	}
109123
 	if err := h.d.Render.RenderPage(w, r, "notifications/inbox", data); err != nil {
110124
 		h.d.Logger.ErrorContext(r.Context(), "notifications: render", "error", err)
@@ -149,7 +163,7 @@ func (h *Handlers) setRead(w http.ResponseWriter, r *http.Request, read bool) {
149163
 		h.d.Logger.WarnContext(r.Context(), "notifications: set read",
150164
 			"id", id, "read", read, "error", err)
151165
 	}
152
-	http.Redirect(w, r, "/notifications", http.StatusSeeOther)
166
+	http.Redirect(w, r, notificationReturnPath(r), http.StatusSeeOther)
153167
 }
154168
 
155169
 func (h *Handlers) markAllRead(w http.ResponseWriter, r *http.Request) {
@@ -161,7 +175,7 @@ func (h *Handlers) markAllRead(w http.ResponseWriter, r *http.Request) {
161175
 	if err := notifdb.New().MarkAllReadForRecipient(r.Context(), h.d.Pool, viewer.ID); err != nil {
162176
 		h.d.Logger.WarnContext(r.Context(), "notifications: mark-all-read", "error", err)
163177
 	}
164
-	http.Redirect(w, r, "/notifications", http.StatusSeeOther)
178
+	http.Redirect(w, r, notificationReturnPath(r), http.StatusSeeOther)
165179
 }
166180
 
167181
 func (h *Handlers) subscribe(w http.ResponseWriter, r *http.Request) {
@@ -269,3 +283,185 @@ func pageFromRequest(r *http.Request) int {
269283
 	}
270284
 	return n
271285
 }
286
+
287
+func filterFromRequest(r *http.Request) string {
288
+	if r.URL.Query().Get("filter") == "unread" {
289
+		return "unread"
290
+	}
291
+	return ""
292
+}
293
+
294
+func notificationsPageHref(filter string, page int) string {
295
+	q := url.Values{}
296
+	if filter == "unread" {
297
+		q.Set("filter", "unread")
298
+	}
299
+	if page > 1 {
300
+		q.Set("page", strconv.Itoa(page))
301
+	}
302
+	if encoded := q.Encode(); encoded != "" {
303
+		return "/notifications?" + encoded
304
+	}
305
+	return "/notifications"
306
+}
307
+
308
+func notificationReturnPath(r *http.Request) string {
309
+	if err := r.ParseForm(); err != nil {
310
+		return "/notifications"
311
+	}
312
+	returnTo := strings.TrimSpace(r.PostFormValue("return_to"))
313
+	if safeNotificationReturnPath(returnTo) {
314
+		return returnTo
315
+	}
316
+	return "/notifications"
317
+}
318
+
319
+func safeNotificationReturnPath(path string) bool {
320
+	if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "\r\n") {
321
+		return false
322
+	}
323
+	u, err := url.Parse(path)
324
+	if err != nil || u.IsAbs() || u.Host != "" {
325
+		return false
326
+	}
327
+	return u.Path == "/notifications"
328
+}
329
+
330
+type notificationInboxItem struct {
331
+	ID            int64
332
+	Unread        bool
333
+	Icon          string
334
+	StateClass    string
335
+	ReasonLabel   string
336
+	KindLabel     string
337
+	RepoFullName  string
338
+	RepoURL       string
339
+	ThreadURL     string
340
+	ThreadTitle   string
341
+	ThreadNumber  int64
342
+	ActorUsername string
343
+	ActorURL      string
344
+	LastEventAt   time.Time
345
+}
346
+
347
+func notificationInboxItems(rows []notifdb.ListNotificationsForRecipientRow) []notificationInboxItem {
348
+	items := make([]notificationInboxItem, 0, len(rows))
349
+	for _, row := range rows {
350
+		items = append(items, notificationInboxItemFromRow(row))
351
+	}
352
+	return items
353
+}
354
+
355
+func notificationInboxItemFromRow(row notifdb.ListNotificationsForRecipientRow) notificationInboxItem {
356
+	item := notificationInboxItem{
357
+		ID:            row.ID,
358
+		Unread:        row.Unread,
359
+		Icon:          "bell",
360
+		StateClass:    "notice",
361
+		ReasonLabel:   notificationReasonLabel(row.Reason),
362
+		KindLabel:     notificationKindLabel(row.Kind),
363
+		ThreadTitle:   row.ThreadTitle,
364
+		ThreadNumber:  row.ThreadNumber,
365
+		ActorUsername: row.ActorUsername,
366
+		LastEventAt:   row.LastEventAt.Time,
367
+	}
368
+	if row.ActorUsername != "" {
369
+		item.ActorURL = "/" + url.PathEscape(row.ActorUsername)
370
+	}
371
+	if row.RepoOwnerUsername != "" && row.RepoName != "" {
372
+		item.RepoFullName = row.RepoOwnerUsername + "/" + row.RepoName
373
+		item.RepoURL = "/" + url.PathEscape(row.RepoOwnerUsername) + "/" + url.PathEscape(row.RepoName)
374
+	}
375
+	if row.ThreadKind.Valid {
376
+		switch row.ThreadKind.NotificationThreadKind {
377
+		case notifdb.NotificationThreadKindPr:
378
+			item.Icon = "git-pull-request"
379
+			item.StateClass = "pr"
380
+			if item.RepoURL != "" && row.ThreadNumber > 0 {
381
+				item.ThreadURL = item.RepoURL + "/pulls/" + strconv.FormatInt(row.ThreadNumber, 10)
382
+			}
383
+		case notifdb.NotificationThreadKindIssue:
384
+			item.Icon = "issue-opened"
385
+			item.StateClass = "issue"
386
+			if item.RepoURL != "" && row.ThreadNumber > 0 {
387
+				item.ThreadURL = item.RepoURL + "/issues/" + strconv.FormatInt(row.ThreadNumber, 10)
388
+			}
389
+		}
390
+	}
391
+	if item.ThreadTitle == "" {
392
+		item.ThreadTitle = item.KindLabel
393
+	}
394
+	return item
395
+}
396
+
397
+func notificationReasonLabel(reason string) string {
398
+	switch reason {
399
+	case "mention":
400
+		return "Mention"
401
+	case "assignment":
402
+		return "Assigned"
403
+	case "review_requested":
404
+		return "Review requested"
405
+	case "author":
406
+		return "Author"
407
+	case "commenter":
408
+		return "Commenter"
409
+	case "subscribed":
410
+		return "Subscribed"
411
+	case "watching":
412
+		return "Watching"
413
+	case "repo_admin_action":
414
+		return "Repository"
415
+	default:
416
+		return humanizeNotificationToken(reason)
417
+	}
418
+}
419
+
420
+func notificationKindLabel(kind string) string {
421
+	switch kind {
422
+	case "issue_created":
423
+		return "Issue opened"
424
+	case "issue_comment_created":
425
+		return "Issue comment"
426
+	case "issue_assigned":
427
+		return "Issue assigned"
428
+	case "issue_closed":
429
+		return "Issue closed"
430
+	case "issue_reopened":
431
+		return "Issue reopened"
432
+	case "pr_opened":
433
+		return "Pull request opened"
434
+	case "pr_comment_created":
435
+		return "Pull request comment"
436
+	case "pr_assigned":
437
+		return "Pull request assigned"
438
+	case "pr_closed":
439
+		return "Pull request closed"
440
+	case "pr_reopened":
441
+		return "Pull request reopened"
442
+	case "pr_merged":
443
+		return "Pull request merged"
444
+	case "review_requested":
445
+		return "Review requested"
446
+	case "review_submitted":
447
+		return "Review submitted"
448
+	case "mentioned":
449
+		return "Mentioned"
450
+	case "check_failed":
451
+		return "Check failed"
452
+	case "check_fixed":
453
+		return "Check fixed"
454
+	case "repo_archived":
455
+		return "Repository archived"
456
+	default:
457
+		return humanizeNotificationToken(kind)
458
+	}
459
+}
460
+
461
+func humanizeNotificationToken(token string) string {
462
+	token = strings.TrimSpace(strings.ReplaceAll(token, "_", " "))
463
+	if token == "" {
464
+		return "Notification"
465
+	}
466
+	return strings.ToUpper(token[:1]) + token[1:]
467
+}
internal/web/handlers/notifications/notifications_test.goadded
@@ -0,0 +1,113 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package notifications
4
+
5
+import (
6
+	"net/http/httptest"
7
+	"testing"
8
+	"time"
9
+
10
+	"github.com/jackc/pgx/v5/pgtype"
11
+
12
+	notifdb "github.com/tenseleyFlow/shithub/internal/notif/sqlc"
13
+)
14
+
15
+func TestNotificationsPageHref(t *testing.T) {
16
+	t.Parallel()
17
+
18
+	tests := []struct {
19
+		name   string
20
+		filter string
21
+		page   int
22
+		want   string
23
+	}{
24
+		{name: "all first page", want: "/notifications"},
25
+		{name: "all later page", page: 3, want: "/notifications?page=3"},
26
+		{name: "unread first page", filter: "unread", want: "/notifications?filter=unread"},
27
+		{name: "unread later page", filter: "unread", page: 2, want: "/notifications?filter=unread&page=2"},
28
+		{name: "unknown filter drops", filter: "done", page: 1, want: "/notifications"},
29
+	}
30
+	for _, tt := range tests {
31
+		t.Run(tt.name, func(t *testing.T) {
32
+			t.Parallel()
33
+			if got := notificationsPageHref(tt.filter, tt.page); got != tt.want {
34
+				t.Fatalf("notificationsPageHref(%q, %d) = %q, want %q", tt.filter, tt.page, got, tt.want)
35
+			}
36
+		})
37
+	}
38
+}
39
+
40
+func TestSafeNotificationReturnPath(t *testing.T) {
41
+	t.Parallel()
42
+
43
+	tests := []struct {
44
+		path string
45
+		want bool
46
+	}{
47
+		{path: "/notifications", want: true},
48
+		{path: "/notifications?filter=unread&page=2", want: true},
49
+		{path: "/settings/notifications", want: false},
50
+		{path: "//evil.test/notifications", want: false},
51
+		{path: "https://evil.test/notifications", want: false},
52
+		{path: "/notifications\r\nLocation: https://evil.test", want: false},
53
+	}
54
+	for _, tt := range tests {
55
+		t.Run(tt.path, func(t *testing.T) {
56
+			t.Parallel()
57
+			if got := safeNotificationReturnPath(tt.path); got != tt.want {
58
+				t.Fatalf("safeNotificationReturnPath(%q) = %v, want %v", tt.path, got, tt.want)
59
+			}
60
+		})
61
+	}
62
+}
63
+
64
+func TestFilterFromRequestSanitizesUnsupportedFilters(t *testing.T) {
65
+	t.Parallel()
66
+
67
+	req := httptest.NewRequest("GET", "/notifications?filter=done", nil)
68
+	if got := filterFromRequest(req); got != "" {
69
+		t.Fatalf("filterFromRequest(done) = %q, want empty", got)
70
+	}
71
+	req = httptest.NewRequest("GET", "/notifications?filter=unread", nil)
72
+	if got := filterFromRequest(req); got != "unread" {
73
+		t.Fatalf("filterFromRequest(unread) = %q, want unread", got)
74
+	}
75
+}
76
+
77
+func TestNotificationInboxItemFromPullRequestRow(t *testing.T) {
78
+	t.Parallel()
79
+
80
+	when := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
81
+	item := notificationInboxItemFromRow(notifdb.ListNotificationsForRecipientRow{
82
+		ID:                42,
83
+		Kind:              "review_requested",
84
+		Reason:            "review_requested",
85
+		Unread:            true,
86
+		ThreadKind:        notifdb.NullNotificationThreadKind{NotificationThreadKind: notifdb.NotificationThreadKindPr, Valid: true},
87
+		LastEventAt:       pgtype.Timestamptz{Time: when, Valid: true},
88
+		ActorUsername:     "mona",
89
+		RepoOwnerUsername: "octo-org",
90
+		RepoName:          "hello-world",
91
+		ThreadNumber:      17,
92
+		ThreadTitle:       "Add notification parity",
93
+	})
94
+
95
+	if item.ThreadURL != "/octo-org/hello-world/pulls/17" {
96
+		t.Fatalf("ThreadURL = %q", item.ThreadURL)
97
+	}
98
+	if item.RepoURL != "/octo-org/hello-world" || item.RepoFullName != "octo-org/hello-world" {
99
+		t.Fatalf("repo link = %q %q", item.RepoURL, item.RepoFullName)
100
+	}
101
+	if item.ActorURL != "/mona" || item.ActorUsername != "mona" {
102
+		t.Fatalf("actor link = %q %q", item.ActorURL, item.ActorUsername)
103
+	}
104
+	if item.Icon != "git-pull-request" || item.StateClass != "pr" {
105
+		t.Fatalf("state = %q %q", item.Icon, item.StateClass)
106
+	}
107
+	if item.KindLabel != "Review requested" || item.ReasonLabel != "Review requested" {
108
+		t.Fatalf("labels = %q %q", item.KindLabel, item.ReasonLabel)
109
+	}
110
+	if !item.LastEventAt.Equal(when) {
111
+		t.Fatalf("LastEventAt = %v, want %v", item.LastEventAt, when)
112
+	}
113
+}
internal/web/static/css/shithub.cssmodified
@@ -1038,6 +1038,335 @@ code {
10381038
 .shithub-notif-row strong { display: block; font-size: 0.95rem; }
10391039
 .shithub-notif-row small { display: block; color: var(--fg-muted); font-size: 0.85rem; }
10401040
 
1041
+.shithub-notifications-page {
1042
+  padding: 1.5rem 1rem 3rem;
1043
+}
1044
+.shithub-notifications-shell {
1045
+  display: grid;
1046
+  grid-template-columns: 240px minmax(0, 1fr);
1047
+  gap: 1.5rem;
1048
+  max-width: 1120px;
1049
+  margin: 0 auto;
1050
+}
1051
+.shithub-notifications-sidebar {
1052
+  position: sticky;
1053
+  top: 1rem;
1054
+  align-self: start;
1055
+}
1056
+.shithub-notifications-filter-list {
1057
+  display: flex;
1058
+  flex-direction: column;
1059
+  gap: 0.15rem;
1060
+}
1061
+.shithub-notifications-filter {
1062
+  display: flex;
1063
+  align-items: center;
1064
+  justify-content: space-between;
1065
+  gap: 0.75rem;
1066
+  min-height: 2rem;
1067
+  padding: 0.35rem 0.55rem;
1068
+  border-radius: 6px;
1069
+  color: var(--fg-default);
1070
+  font-size: 0.875rem;
1071
+}
1072
+.shithub-notifications-filter:hover {
1073
+  background: var(--canvas-subtle);
1074
+  text-decoration: none;
1075
+}
1076
+.shithub-notifications-filter.is-selected {
1077
+  background: var(--canvas-subtle);
1078
+  font-weight: 600;
1079
+}
1080
+.shithub-notifications-filter.is-selected::before {
1081
+  content: "";
1082
+  width: 4px;
1083
+  align-self: stretch;
1084
+  margin: -0.4rem 0 -0.4rem -0.65rem;
1085
+  border-radius: 6px 0 0 6px;
1086
+  background: var(--accent-emphasis);
1087
+}
1088
+.shithub-notifications-filter-label {
1089
+  display: inline-flex;
1090
+  align-items: center;
1091
+  gap: 0.45rem;
1092
+  min-width: 0;
1093
+}
1094
+.shithub-notifications-filter-label svg {
1095
+  color: var(--fg-muted);
1096
+  flex: 0 0 auto;
1097
+}
1098
+.shithub-notifications-filter-count {
1099
+  color: var(--fg-muted);
1100
+  font-size: 0.75rem;
1101
+  font-weight: 500;
1102
+}
1103
+.shithub-notifications-main {
1104
+  min-width: 0;
1105
+}
1106
+.shithub-notifications-head {
1107
+  display: flex;
1108
+  align-items: flex-start;
1109
+  justify-content: space-between;
1110
+  gap: 1rem;
1111
+  margin-bottom: 1rem;
1112
+}
1113
+.shithub-notifications-head h1 {
1114
+  margin: 0;
1115
+  font-size: 1.5rem;
1116
+  line-height: 1.25;
1117
+  font-weight: 600;
1118
+}
1119
+.shithub-notifications-head p {
1120
+  margin: 0.25rem 0 0;
1121
+  color: var(--fg-muted);
1122
+  font-size: 0.875rem;
1123
+}
1124
+.shithub-notifications-head-actions {
1125
+  display: flex;
1126
+  gap: 0.5rem;
1127
+  align-items: center;
1128
+  justify-content: flex-end;
1129
+  flex-wrap: wrap;
1130
+}
1131
+.shithub-notifications-head-actions form {
1132
+  margin: 0;
1133
+}
1134
+.shithub-notifications-toolbar {
1135
+  display: flex;
1136
+  align-items: center;
1137
+  justify-content: space-between;
1138
+  gap: 1rem;
1139
+  padding: 0.65rem 1rem;
1140
+  border: 1px solid var(--border-default);
1141
+  border-bottom: 0;
1142
+  border-radius: 6px 6px 0 0;
1143
+  background: var(--canvas-subtle);
1144
+}
1145
+.shithub-notifications-tabs {
1146
+  display: inline-flex;
1147
+  gap: 0.25rem;
1148
+}
1149
+.shithub-notifications-tabs a {
1150
+  display: inline-flex;
1151
+  align-items: center;
1152
+  min-height: 2rem;
1153
+  padding: 0.3rem 0.75rem;
1154
+  border-radius: 6px;
1155
+  color: var(--fg-default);
1156
+  font-size: 0.875rem;
1157
+  font-weight: 500;
1158
+}
1159
+.shithub-notifications-tabs a:hover {
1160
+  background: var(--canvas-default);
1161
+  text-decoration: none;
1162
+}
1163
+.shithub-notifications-tabs a.is-selected {
1164
+  background: var(--canvas-default);
1165
+  box-shadow: inset 0 0 0 1px var(--border-default);
1166
+}
1167
+.shithub-notifications-page-num {
1168
+  color: var(--fg-muted);
1169
+  font-size: 0.75rem;
1170
+}
1171
+.shithub-notifications-list {
1172
+  list-style: none;
1173
+  padding: 0;
1174
+  margin: 0;
1175
+  border: 1px solid var(--border-default);
1176
+  border-radius: 0 0 6px 6px;
1177
+  overflow: hidden;
1178
+}
1179
+.shithub-notification-row {
1180
+  display: grid;
1181
+  grid-template-columns: 12px 20px minmax(0, 1fr) auto;
1182
+  gap: 0.75rem;
1183
+  align-items: start;
1184
+  min-height: 76px;
1185
+  padding: 1rem;
1186
+  background: var(--canvas-default);
1187
+  border-top: 1px solid var(--border-default);
1188
+}
1189
+.shithub-notification-row:first-child {
1190
+  border-top: 0;
1191
+}
1192
+.shithub-notification-row.is-unread {
1193
+  background: rgba(56, 139, 253, 0.06);
1194
+}
1195
+.shithub-notification-unread-dot {
1196
+  width: 8px;
1197
+  height: 8px;
1198
+  margin-top: 0.45rem;
1199
+  border-radius: 50%;
1200
+}
1201
+.shithub-notification-row.is-unread .shithub-notification-unread-dot {
1202
+  background: var(--accent-emphasis);
1203
+}
1204
+.shithub-notification-state {
1205
+  display: inline-flex;
1206
+  align-items: center;
1207
+  justify-content: center;
1208
+  width: 20px;
1209
+  height: 20px;
1210
+  margin-top: 0.05rem;
1211
+  color: var(--fg-muted);
1212
+}
1213
+.shithub-notification-state-issue svg { color: var(--success-fg); }
1214
+.shithub-notification-state-pr svg { color: var(--accent-fg); }
1215
+.shithub-notification-content {
1216
+  min-width: 0;
1217
+}
1218
+.shithub-notification-context {
1219
+  display: flex;
1220
+  align-items: center;
1221
+  gap: 0.45rem;
1222
+  flex-wrap: wrap;
1223
+  color: var(--fg-muted);
1224
+  font-size: 0.75rem;
1225
+}
1226
+.shithub-notification-context a {
1227
+  color: var(--fg-default);
1228
+  font-weight: 600;
1229
+}
1230
+.shithub-notification-context span {
1231
+  padding: 0.05rem 0.45rem;
1232
+  border: 1px solid var(--border-default);
1233
+  border-radius: 999px;
1234
+  background: var(--canvas-subtle);
1235
+  color: var(--fg-muted);
1236
+  font-weight: 500;
1237
+}
1238
+.shithub-notification-title {
1239
+  margin-top: 0.2rem;
1240
+  color: var(--fg-default);
1241
+  font-size: 0.95rem;
1242
+  font-weight: 600;
1243
+  line-height: 1.35;
1244
+}
1245
+.shithub-notification-title a,
1246
+.shithub-notification-title strong {
1247
+  color: var(--fg-default);
1248
+}
1249
+.shithub-notification-title a:hover {
1250
+  color: var(--accent-fg);
1251
+}
1252
+.shithub-notification-meta {
1253
+  display: flex;
1254
+  gap: 0.45rem;
1255
+  flex-wrap: wrap;
1256
+  margin-top: 0.35rem;
1257
+  color: var(--fg-muted);
1258
+  font-size: 0.75rem;
1259
+}
1260
+.shithub-notification-meta a {
1261
+  color: var(--fg-muted);
1262
+}
1263
+.shithub-notification-meta a:hover {
1264
+  color: var(--accent-fg);
1265
+  text-decoration: none;
1266
+}
1267
+.shithub-notification-meta > * + *::before {
1268
+  content: "";
1269
+  display: inline-block;
1270
+  width: 3px;
1271
+  height: 3px;
1272
+  margin: 0 0.45rem 0 0;
1273
+  border-radius: 50%;
1274
+  background: var(--fg-muted);
1275
+  opacity: 0.7;
1276
+  vertical-align: middle;
1277
+}
1278
+.shithub-notification-actions {
1279
+  display: flex;
1280
+  justify-content: flex-end;
1281
+}
1282
+.shithub-notification-actions form {
1283
+  margin: 0;
1284
+}
1285
+.shithub-notification-actions .shithub-icon-button {
1286
+  background: transparent;
1287
+}
1288
+.shithub-notification-actions .shithub-icon-button:hover {
1289
+  background: var(--canvas-subtle);
1290
+}
1291
+.shithub-notifications-pagination {
1292
+  display: flex;
1293
+  justify-content: center;
1294
+  gap: 0.5rem;
1295
+  padding: 1rem 0 0;
1296
+}
1297
+.shithub-notifications-empty {
1298
+  display: grid;
1299
+  justify-items: center;
1300
+  gap: 0.4rem;
1301
+  padding: 3rem 1rem;
1302
+  border: 1px solid var(--border-default);
1303
+  border-radius: 0 0 6px 6px;
1304
+  color: var(--fg-muted);
1305
+  text-align: center;
1306
+}
1307
+.shithub-notifications-empty-icon {
1308
+  display: inline-flex;
1309
+  align-items: center;
1310
+  justify-content: center;
1311
+  width: 48px;
1312
+  height: 48px;
1313
+  border: 1px solid var(--border-default);
1314
+  border-radius: 50%;
1315
+  color: var(--fg-muted);
1316
+}
1317
+.shithub-notifications-empty-icon svg {
1318
+  width: 24px;
1319
+  height: 24px;
1320
+}
1321
+.shithub-notifications-empty h2 {
1322
+  margin: 0.25rem 0 0;
1323
+  color: var(--fg-default);
1324
+  font-size: 1rem;
1325
+}
1326
+.shithub-notifications-empty p {
1327
+  margin: 0;
1328
+  font-size: 0.875rem;
1329
+}
1330
+@media (max-width: 760px) {
1331
+  .shithub-notifications-page {
1332
+    padding: 1rem;
1333
+  }
1334
+  .shithub-notifications-shell {
1335
+    grid-template-columns: 1fr;
1336
+    gap: 1rem;
1337
+  }
1338
+  .shithub-notifications-sidebar {
1339
+    position: static;
1340
+  }
1341
+  .shithub-notifications-filter-list {
1342
+    flex-direction: row;
1343
+    gap: 0.25rem;
1344
+    overflow-x: auto;
1345
+    padding-bottom: 0.25rem;
1346
+  }
1347
+  .shithub-notifications-filter {
1348
+    flex: 0 0 auto;
1349
+  }
1350
+  .shithub-notifications-filter.is-selected::before {
1351
+    display: none;
1352
+  }
1353
+  .shithub-notifications-head,
1354
+  .shithub-notifications-toolbar {
1355
+    align-items: stretch;
1356
+    flex-direction: column;
1357
+  }
1358
+  .shithub-notifications-head-actions {
1359
+    justify-content: flex-start;
1360
+  }
1361
+  .shithub-notification-row {
1362
+    grid-template-columns: 12px 20px minmax(0, 1fr);
1363
+  }
1364
+  .shithub-notification-actions {
1365
+    grid-column: 3;
1366
+    justify-content: flex-start;
1367
+  }
1368
+}
1369
+
10411370
 .shithub-session-meta {
10421371
   display: grid;
10431372
   grid-template-columns: max-content 1fr;
internal/web/templates/notifications/inbox.htmlmodified
@@ -1,61 +1,103 @@
11
 {{ define "page" -}}
2
-<section class="shithub-notifications">
3
-  <header class="shithub-notifications-head">
4
-    <h1>Notifications</h1>
5
-    <nav class="shithub-tabs">
6
-      <a href="/notifications" class="shithub-button {{ if ne .Filter "unread" }}shithub-button-primary{{ end }}">All</a>
7
-      <a href="/notifications?filter=unread" class="shithub-button {{ if eq .Filter "unread" }}shithub-button-primary{{ end }}">Unread ({{ .UnreadCount }})</a>
8
-    </nav>
9
-    {{ if .Notifications }}
10
-    <form method="post" action="/notifications/mark-all-read" style="display:inline">
11
-      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
12
-      <button type="submit" class="shithub-button">Mark all read</button>
13
-    </form>
14
-    {{ end }}
15
-  </header>
2
+<section class="shithub-notifications-page">
3
+  <div class="shithub-notifications-shell">
4
+    <aside class="shithub-notifications-sidebar" aria-label="Notification filters">
5
+      <nav class="shithub-notifications-filter-list">
6
+        <a href="{{ .AllHref }}" class="shithub-notifications-filter{{ if ne .Filter "unread" }} is-selected{{ end }}"{{ if ne .Filter "unread" }} aria-current="page"{{ end }}>
7
+          <span class="shithub-notifications-filter-label">{{ octicon "bell" }} Inbox</span>
8
+          <span class="shithub-notifications-filter-count">{{ .AllCount }}</span>
9
+        </a>
10
+        <a href="{{ .UnreadHref }}" class="shithub-notifications-filter{{ if eq .Filter "unread" }} is-selected{{ end }}"{{ if eq .Filter "unread" }} aria-current="page"{{ end }}>
11
+          <span class="shithub-notifications-filter-label">{{ octicon "dot-fill" }} Unread</span>
12
+          <span class="shithub-notifications-filter-count">{{ .UnreadCount }}</span>
13
+        </a>
14
+      </nav>
15
+    </aside>
1616
 
17
-  {{ if .Notifications }}
18
-    <ul class="shithub-notifications-list">
19
-      {{ range .Notifications }}
20
-      <li class="shithub-notification-row {{ if .Unread }}is-unread{{ end }}">
21
-        <div class="shithub-notification-meta">
22
-          <span class="shithub-pill shithub-pill-reason">{{ .Reason }}</span>
23
-          {{ if .RepoOwnerUsername }}
24
-            <a href="/{{ .RepoOwnerUsername }}/{{ .RepoName }}">{{ .RepoOwnerUsername }}/{{ .RepoName }}</a>
25
-          {{ end }}
17
+    <div class="shithub-notifications-main">
18
+      <header class="shithub-notifications-head">
19
+        <div>
20
+          <h1>Notifications</h1>
21
+          <p>{{ if eq .Filter "unread" }}Unread notifications{{ else }}Inbox{{ end }}</p>
2622
         </div>
27
-        <div class="shithub-notification-body">
28
-          {{ if .ThreadNumber }}
29
-            <a href="/{{ .RepoOwnerUsername }}/{{ .RepoName }}/{{ if eq (printf "%s" .ThreadKind.NotificationThreadKind) "pr" }}pulls{{ else }}issues{{ end }}/{{ .ThreadNumber }}">
30
-              <strong>#{{ .ThreadNumber }} {{ .ThreadTitle }}</strong>
31
-            </a>
32
-          {{ else }}
33
-            <strong>{{ .Kind }}</strong>
23
+        <div class="shithub-notifications-head-actions">
24
+          <a href="/settings/notifications" class="shithub-button">{{ octicon "gear" }} Settings</a>
25
+          {{ if gt .UnreadCount 0 }}
26
+          <form method="post" action="/notifications/mark-all-read">
27
+            <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
28
+            <input type="hidden" name="return_to" value="{{ .CurrentURL }}">
29
+            <button type="submit" class="shithub-button">{{ octicon "check-circle" }} Mark all as read</button>
30
+          </form>
3431
           {{ end }}
35
-          <small>by @{{ .ActorUsername }} · {{ relativeTime .LastEventAt.Time }}</small>
3632
         </div>
37
-        <div class="shithub-notification-actions">
38
-          {{ if .Unread }}
39
-          <form method="post" action="/notifications/{{ .ID }}/read" style="display:inline">
40
-            <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
41
-            <button type="submit" class="shithub-button">Mark read</button>
42
-          </form>
43
-          {{ else }}
44
-          <form method="post" action="/notifications/{{ .ID }}/unread" style="display:inline">
45
-            <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
46
-            <button type="submit" class="shithub-button">Mark unread</button>
47
-          </form>
33
+      </header>
34
+
35
+      <div class="shithub-notifications-toolbar">
36
+        <nav class="shithub-notifications-tabs" aria-label="Notification view">
37
+          <a href="{{ .AllHref }}" class="{{ if ne .Filter "unread" }}is-selected{{ end }}"{{ if ne .Filter "unread" }} aria-current="page"{{ end }}>All</a>
38
+          <a href="{{ .UnreadHref }}" class="{{ if eq .Filter "unread" }}is-selected{{ end }}"{{ if eq .Filter "unread" }} aria-current="page"{{ end }}>Unread</a>
39
+        </nav>
40
+        {{ if gt .Page 1 }}<span class="shithub-notifications-page-num">Page {{ .Page }}</span>{{ end }}
41
+      </div>
42
+
43
+      {{ if .Notifications }}
44
+        <ol class="shithub-notifications-list">
45
+          {{ range .Notifications }}
46
+          <li class="shithub-notification-row {{ if .Unread }}is-unread{{ end }}">
47
+            <span class="shithub-notification-unread-dot" aria-hidden="true"></span>
48
+            <span class="shithub-notification-state shithub-notification-state-{{ .StateClass }}" aria-hidden="true">{{ octicon .Icon }}</span>
49
+            <div class="shithub-notification-content">
50
+              <div class="shithub-notification-context">
51
+                {{ if .RepoURL }}<a href="{{ .RepoURL }}">{{ .RepoFullName }}</a>{{ end }}
52
+                <span>{{ .ReasonLabel }}</span>
53
+              </div>
54
+              <div class="shithub-notification-title">
55
+                {{ if .ThreadURL }}
56
+                  <a href="{{ .ThreadURL }}">{{ if .ThreadNumber }}#{{ .ThreadNumber }} {{ end }}{{ .ThreadTitle }}</a>
57
+                {{ else }}
58
+                  <strong>{{ .ThreadTitle }}</strong>
59
+                {{ end }}
60
+              </div>
61
+              <div class="shithub-notification-meta">
62
+                <span>{{ .KindLabel }}</span>
63
+                {{ if .ActorUsername }}
64
+                  <span>by <a href="{{ .ActorURL }}">@{{ .ActorUsername }}</a></span>
65
+                {{ end }}
66
+                <time datetime="{{ .LastEventAt.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .LastEventAt }}</time>
67
+              </div>
68
+            </div>
69
+            <div class="shithub-notification-actions">
70
+              {{ if .Unread }}
71
+              <form method="post" action="/notifications/{{ .ID }}/read">
72
+                <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
73
+                <input type="hidden" name="return_to" value="{{ $.CurrentURL }}">
74
+                <button type="submit" class="shithub-icon-button" title="Mark as read" aria-label="Mark as read">{{ octicon "check-circle" }}</button>
75
+              </form>
76
+              {{ else }}
77
+              <form method="post" action="/notifications/{{ .ID }}/unread">
78
+                <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
79
+                <input type="hidden" name="return_to" value="{{ $.CurrentURL }}">
80
+                <button type="submit" class="shithub-icon-button" title="Mark as unread" aria-label="Mark as unread">{{ octicon "bell" }}</button>
81
+              </form>
82
+              {{ end }}
83
+            </div>
84
+          </li>
4885
           {{ end }}
86
+        </ol>
87
+        {{ if or .HasPrev .HasNext }}
88
+        <nav class="shithub-notifications-pagination" aria-label="Notifications pagination">
89
+          {{ if .HasPrev }}<a href="{{ .PrevHref }}" class="shithub-button">Newer</a>{{ else }}<span class="shithub-button shithub-button-disabled" aria-disabled="true">Newer</span>{{ end }}
90
+          {{ if .HasNext }}<a href="{{ .NextHref }}" class="shithub-button">Older</a>{{ else }}<span class="shithub-button shithub-button-disabled" aria-disabled="true">Older</span>{{ end }}
91
+        </nav>
92
+        {{ end }}
93
+      {{ else }}
94
+        <div class="shithub-notifications-empty">
95
+          <div class="shithub-notifications-empty-icon">{{ octicon "bell" }}</div>
96
+          <h2>{{ if eq .Filter "unread" }}No unread notifications{{ else }}No notifications{{ end }}</h2>
97
+          <p>You're all caught up.</p>
4998
         </div>
50
-      </li>
5199
       {{ end }}
52
-    </ul>
53
-    <nav class="shithub-pagination">
54
-      {{ if .HasPrev }}<a href="?filter={{ .Filter }}&page={{ sub .Page 1 }}">&larr; Newer</a>{{ end }}
55
-      {{ if .HasNext }}<a href="?filter={{ .Filter }}&page={{ add .Page 1 }}">Older &rarr;</a>{{ end }}
56
-    </nav>
57
-  {{ else }}
58
-    <p class="shithub-empty">{{ if eq .Filter "unread" }}Nothing unread.{{ else }}No notifications.{{ end }}</p>
59
-  {{ end }}
100
+    </div>
101
+  </div>
60102
 </section>
61103
 {{- end }}