Align notifications inbox UI
- SHA
462ee9ee7de241dcb405ea9786f300b4bb323164- Parents
-
f305fee - Tree
ab9fbea
462ee9e
462ee9ee7de241dcb405ea9786f300b4bb323164f305fee
ab9fbea| Status | File | + | - |
|---|---|---|---|
| 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. | ||
| 145 | 145 | | POST | `/threads/{kind}/{id}/unsubscribe` | required | Per-thread unsubscribe override | |
| 146 | 146 | | GET | `/notifications/unsubscribe` | none | One-click HMAC-signed unsub | |
| 147 | 147 | |
| 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 | + | |
| 148 | 158 | ## What we deferred from the spec |
| 149 | 159 | |
| 150 | 160 | * **API endpoint** `GET /api/v1/notifications` + the per-thread sub |
internal/web/handlers/notifications/notifications.gomodified@@ -15,7 +15,10 @@ import ( | ||
| 15 | 15 | "errors" |
| 16 | 16 | "log/slog" |
| 17 | 17 | "net/http" |
| 18 | + "net/url" | |
| 18 | 19 | "strconv" |
| 20 | + "strings" | |
| 21 | + "time" | |
| 19 | 22 | |
| 20 | 23 | "github.com/go-chi/chi/v5" |
| 21 | 24 | "github.com/jackc/pgx/v5/pgxpool" |
@@ -81,7 +84,8 @@ func (h *Handlers) list(w http.ResponseWriter, r *http.Request) { | ||
| 81 | 84 | return |
| 82 | 85 | } |
| 83 | 86 | page := pageFromRequest(r) |
| 84 | - onlyUnread := r.URL.Query().Get("filter") == "unread" | |
| 87 | + filter := filterFromRequest(r) | |
| 88 | + onlyUnread := filter == "unread" | |
| 85 | 89 | |
| 86 | 90 | q := notifdb.New() |
| 87 | 91 | 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) { | ||
| 96 | 100 | return |
| 97 | 101 | } |
| 98 | 102 | 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 | + }) | |
| 99 | 107 | |
| 100 | 108 | data := map[string]any{ |
| 101 | 109 | "Title": "Notifications", |
| 102 | - "Notifications": rows, | |
| 110 | + "Notifications": notificationInboxItems(rows), | |
| 111 | + "AllCount": allCount, | |
| 103 | 112 | "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(), | |
| 105 | 117 | "Page": page, |
| 106 | 118 | "HasPrev": page > 1, |
| 107 | 119 | "HasNext": len(rows) == pageSize, |
| 120 | + "PrevHref": notificationsPageHref(filter, page-1), | |
| 121 | + "NextHref": notificationsPageHref(filter, page+1), | |
| 108 | 122 | } |
| 109 | 123 | if err := h.d.Render.RenderPage(w, r, "notifications/inbox", data); err != nil { |
| 110 | 124 | 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) { | ||
| 149 | 163 | h.d.Logger.WarnContext(r.Context(), "notifications: set read", |
| 150 | 164 | "id", id, "read", read, "error", err) |
| 151 | 165 | } |
| 152 | - http.Redirect(w, r, "/notifications", http.StatusSeeOther) | |
| 166 | + http.Redirect(w, r, notificationReturnPath(r), http.StatusSeeOther) | |
| 153 | 167 | } |
| 154 | 168 | |
| 155 | 169 | func (h *Handlers) markAllRead(w http.ResponseWriter, r *http.Request) { |
@@ -161,7 +175,7 @@ func (h *Handlers) markAllRead(w http.ResponseWriter, r *http.Request) { | ||
| 161 | 175 | if err := notifdb.New().MarkAllReadForRecipient(r.Context(), h.d.Pool, viewer.ID); err != nil { |
| 162 | 176 | h.d.Logger.WarnContext(r.Context(), "notifications: mark-all-read", "error", err) |
| 163 | 177 | } |
| 164 | - http.Redirect(w, r, "/notifications", http.StatusSeeOther) | |
| 178 | + http.Redirect(w, r, notificationReturnPath(r), http.StatusSeeOther) | |
| 165 | 179 | } |
| 166 | 180 | |
| 167 | 181 | func (h *Handlers) subscribe(w http.ResponseWriter, r *http.Request) { |
@@ -269,3 +283,185 @@ func pageFromRequest(r *http.Request) int { | ||
| 269 | 283 | } |
| 270 | 284 | return n |
| 271 | 285 | } |
| 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 { | ||
| 1038 | 1038 | .shithub-notif-row strong { display: block; font-size: 0.95rem; } |
| 1039 | 1039 | .shithub-notif-row small { display: block; color: var(--fg-muted); font-size: 0.85rem; } |
| 1040 | 1040 | |
| 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 | + | |
| 1041 | 1370 | .shithub-session-meta { |
| 1042 | 1371 | display: grid; |
| 1043 | 1372 | grid-template-columns: max-content 1fr; |
internal/web/templates/notifications/inbox.htmlmodified@@ -1,61 +1,103 @@ | ||
| 1 | 1 | {{ 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> | |
| 16 | 16 | |
| 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> | |
| 26 | 22 | </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> | |
| 34 | 31 | {{ end }} |
| 35 | - <small>by @{{ .ActorUsername }} · {{ relativeTime .LastEventAt.Time }}</small> | |
| 36 | 32 | </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> | |
| 48 | 85 | {{ 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> | |
| 49 | 98 | </div> |
| 50 | - </li> | |
| 51 | 99 | {{ end }} |
| 52 | - </ul> | |
| 53 | - <nav class="shithub-pagination"> | |
| 54 | - {{ if .HasPrev }}<a href="?filter={{ .Filter }}&page={{ sub .Page 1 }}">← Newer</a>{{ end }} | |
| 55 | - {{ if .HasNext }}<a href="?filter={{ .Filter }}&page={{ add .Page 1 }}">Older →</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> | |
| 60 | 102 | </section> |
| 61 | 103 | {{- end }} |