Append Explore feed pages in place
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
30121660b7828553f1cd0af67808ddfe4309d601- Parents
-
c43bec0 - Tree
96a5710
3012166
30121660b7828553f1cd0af67808ddfe4309d601c43bec0
96a5710| Status | File | + | - |
|---|---|---|---|
| M |
docs/internal/social.md
|
5 | 1 |
| M |
internal/web/embed_test.go
|
28 | 0 |
| M |
internal/web/handlers/explore.go
|
27 | 0 |
| A |
internal/web/handlers/explore_test.go
|
71 | 0 |
| M |
internal/web/static/css/shithub.css
|
15 | 0 |
| A |
internal/web/templates/_explore_feed_pagination.html
|
21 | 0 |
| A |
internal/web/templates/explore/feed_page.html
|
6 | 0 |
| M |
internal/web/templates/explore/index.html
|
2 | 6 |
internal/web/embed_test.gomodified@@ -120,6 +120,34 @@ func TestOrgPagesRenderSingleSharedOrgNav(t *testing.T) { | ||
| 120 | 120 | } |
| 121 | 121 | } |
| 122 | 122 | |
| 123 | +func TestExploreFeedFragmentAppendsRowsAndReplacesPagination(t *testing.T) { | |
| 124 | + t.Parallel() | |
| 125 | + r, err := render.New(TemplatesFS(), render.Options{}) | |
| 126 | + if err != nil { | |
| 127 | + t.Fatalf("render.New: %v", err) | |
| 128 | + } | |
| 129 | + rw := httptest.NewRecorder() | |
| 130 | + if err := r.RenderFragment(rw, "explore/feed_page", map[string]any{ | |
| 131 | + "FeedHasNext": true, | |
| 132 | + "FeedNextURL": "/explore?before=2026-05-12T03%3A00%3A00Z~42", | |
| 133 | + }); err != nil { | |
| 134 | + t.Fatalf("RenderFragment: %v", err) | |
| 135 | + } | |
| 136 | + body := rw.Body.String() | |
| 137 | + for _, want := range []string{ | |
| 138 | + `id="shithub-feed-fragment-rows"`, | |
| 139 | + `hx-target="#shithub-feed-list"`, | |
| 140 | + `hx-swap="beforeend"`, | |
| 141 | + `hx-select="#shithub-feed-fragment-rows > *"`, | |
| 142 | + `hx-select-oob="#shithub-feed-pagination:outerHTML"`, | |
| 143 | + `Loading...`, | |
| 144 | + } { | |
| 145 | + if !strings.Contains(body, want) { | |
| 146 | + t.Fatalf("fragment missing %q in:\n%s", want, body) | |
| 147 | + } | |
| 148 | + } | |
| 149 | +} | |
| 150 | + | |
| 123 | 151 | // errorOriginatesInPartial returns true when an html/template execute |
| 124 | 152 | // error blames a file whose basename starts with `_`. Errors from such |
| 125 | 153 | // files are bugs in the partial because we render with an empty map |
internal/web/handlers/explore.gomodified@@ -52,6 +52,10 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat | ||
| 52 | 52 | } |
| 53 | 53 | return social.PublicFeed(r.Context(), deps, cursor, limit) |
| 54 | 54 | }) |
| 55 | + if activeTab == "activity" && isExploreFeedFragmentRequest(r) { | |
| 56 | + h.renderFeedFragment(w, r, feed, hasNext, nextURL) | |
| 57 | + return | |
| 58 | + } | |
| 55 | 59 | if viewer.ID != 0 { |
| 56 | 60 | var err error |
| 57 | 61 | topRepos, err = social.DashboardRepos(r.Context(), deps, viewer.ID, 30) |
@@ -73,6 +77,10 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat | ||
| 73 | 77 | h.logger.WarnContext(r.Context(), "explore trending users", "error", err) |
| 74 | 78 | } |
| 75 | 79 | } |
| 80 | + if activeTab == "activity" && isExploreFeedFragmentRequest(r) { | |
| 81 | + h.renderFeedFragment(w, r, feed, hasNext, nextURL) | |
| 82 | + return | |
| 83 | + } | |
| 76 | 84 | |
| 77 | 85 | pageHeading := title |
| 78 | 86 | feedHeading := "Public activity" |
@@ -102,6 +110,7 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat | ||
| 102 | 110 | "TrendingRepos": trendingRepos, |
| 103 | 111 | "TrendingUsers": trendingUsers, |
| 104 | 112 | "Path": path, |
| 113 | + "UseHTMX": true, | |
| 105 | 114 | } |
| 106 | 115 | if err := h.render.RenderPage(w, r, "explore/index", data); err != nil { |
| 107 | 116 | if h.logger != nil { |
@@ -111,6 +120,24 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat | ||
| 111 | 120 | } |
| 112 | 121 | } |
| 113 | 122 | |
| 123 | +func (h exploreHandler) renderFeedFragment(w http.ResponseWriter, r *http.Request, feed []social.FeedItem, hasNext bool, nextURL string) { | |
| 124 | + data := map[string]any{ | |
| 125 | + "Feed": feed, | |
| 126 | + "FeedHasNext": hasNext, | |
| 127 | + "FeedNextURL": nextURL, | |
| 128 | + } | |
| 129 | + if err := h.render.RenderFragment(w, "explore/feed_page", data); err != nil { | |
| 130 | + if h.logger != nil { | |
| 131 | + h.logger.ErrorContext(r.Context(), "render explore feed fragment", "error", err) | |
| 132 | + } | |
| 133 | + http.Error(w, "internal server error", http.StatusInternalServerError) | |
| 134 | + } | |
| 135 | +} | |
| 136 | + | |
| 137 | +func isExploreFeedFragmentRequest(r *http.Request) bool { | |
| 138 | + return r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("before") != "" | |
| 139 | +} | |
| 140 | + | |
| 114 | 141 | func feedPageFor(r *http.Request, load func(social.FeedCursor, int32) ([]social.FeedItem, error)) ([]social.FeedItem, bool, string) { |
| 115 | 142 | items, err := load(parseFeedCursor(r), feedDisplayLimit+1) |
| 116 | 143 | if err != nil { |
internal/web/handlers/explore_test.goadded@@ -0,0 +1,71 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package handlers | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "net/http/httptest" | |
| 7 | + "net/url" | |
| 8 | + "testing" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "github.com/tenseleyFlow/shithub/internal/social" | |
| 12 | +) | |
| 13 | + | |
| 14 | +func TestExploreFeedFragmentRequestRequiresHTMXAndCursor(t *testing.T) { | |
| 15 | + t.Parallel() | |
| 16 | + | |
| 17 | + req := httptest.NewRequest("GET", "/explore?before=2026-05-12T00:00:00Z~42", nil) | |
| 18 | + if isExploreFeedFragmentRequest(req) { | |
| 19 | + t.Fatal("plain cursor request should stay a full-page no-JS fallback") | |
| 20 | + } | |
| 21 | + | |
| 22 | + req.Header.Set("HX-Request", "true") | |
| 23 | + if !isExploreFeedFragmentRequest(req) { | |
| 24 | + t.Fatal("HTMX cursor request should render feed fragment") | |
| 25 | + } | |
| 26 | + | |
| 27 | + req = httptest.NewRequest("GET", "/explore", nil) | |
| 28 | + req.Header.Set("HX-Request", "true") | |
| 29 | + if isExploreFeedFragmentRequest(req) { | |
| 30 | + t.Fatal("HTMX first-page request should not render feed fragment") | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +func TestFeedPageForBuildsNextCursorFromLastDisplayedItem(t *testing.T) { | |
| 35 | + t.Parallel() | |
| 36 | + | |
| 37 | + req := httptest.NewRequest("GET", "/explore?tab=activity", nil) | |
| 38 | + base := time.Date(2026, 5, 12, 14, 0, 0, 0, time.UTC) | |
| 39 | + items := make([]social.FeedItem, 0, feedDisplayLimit+1) | |
| 40 | + for i := int32(0); i < feedDisplayLimit+1; i++ { | |
| 41 | + items = append(items, social.FeedItem{ | |
| 42 | + ID: int64(1000 + i), | |
| 43 | + CreatedAt: base.Add(-time.Duration(i) * time.Minute), | |
| 44 | + }) | |
| 45 | + } | |
| 46 | + | |
| 47 | + got, hasNext, nextURL := feedPageFor(req, func(cursor social.FeedCursor, limit int32) ([]social.FeedItem, error) { | |
| 48 | + if !cursor.BeforeCreatedAt.IsZero() || cursor.BeforeID != 0 { | |
| 49 | + t.Fatalf("first page cursor = %+v, want zero", cursor) | |
| 50 | + } | |
| 51 | + if limit != feedDisplayLimit+1 { | |
| 52 | + t.Fatalf("limit = %d, want %d", limit, feedDisplayLimit+1) | |
| 53 | + } | |
| 54 | + return items, nil | |
| 55 | + }) | |
| 56 | + | |
| 57 | + if len(got) != int(feedDisplayLimit) { | |
| 58 | + t.Fatalf("display item count = %d, want %d", len(got), feedDisplayLimit) | |
| 59 | + } | |
| 60 | + if !hasNext { | |
| 61 | + t.Fatal("hasNext = false, want true") | |
| 62 | + } | |
| 63 | + wantCursor := got[len(got)-1].CreatedAt.UTC().Format(time.RFC3339Nano) + "~" + "1019" | |
| 64 | + parsed, err := url.Parse(nextURL) | |
| 65 | + if err != nil { | |
| 66 | + t.Fatalf("parse nextURL: %v", err) | |
| 67 | + } | |
| 68 | + if parsed.Query().Get("tab") != "activity" || parsed.Query().Get("before") != wantCursor { | |
| 69 | + t.Fatalf("nextURL = %q, want preserved query and cursor %q", nextURL, wantCursor) | |
| 70 | + } | |
| 71 | +} | |
internal/web/static/css/shithub.cssmodified@@ -10483,6 +10483,21 @@ button.shithub-repo-action { | ||
| 10483 | 10483 | justify-content: center; |
| 10484 | 10484 | padding-top: 1rem; |
| 10485 | 10485 | } |
| 10486 | +.shithub-feed-pagination:empty { | |
| 10487 | + display: none; | |
| 10488 | +} | |
| 10489 | +.shithub-feed-more-button { | |
| 10490 | + min-width: 4.5rem; | |
| 10491 | +} | |
| 10492 | +.shithub-feed-more-loading { | |
| 10493 | + display: none; | |
| 10494 | +} | |
| 10495 | +.shithub-feed-more-button.htmx-request .shithub-feed-more-label { | |
| 10496 | + display: none; | |
| 10497 | +} | |
| 10498 | +.shithub-feed-more-button.htmx-request .shithub-feed-more-loading { | |
| 10499 | + display: inline; | |
| 10500 | +} | |
| 10486 | 10501 | .shithub-side-panel { |
| 10487 | 10502 | padding: 0 0 1.25rem; |
| 10488 | 10503 | margin-bottom: 1.25rem; |
internal/web/templates/_explore_feed_pagination.htmladded@@ -0,0 +1,21 @@ | ||
| 1 | +{{ define "explore-feed-pagination" -}} | |
| 2 | +{{ if .FeedHasNext }} | |
| 3 | +<nav id="shithub-feed-pagination" class="shithub-feed-pagination" aria-label="Activity pagination"> | |
| 4 | + <a | |
| 5 | + class="shithub-button shithub-feed-more-button" | |
| 6 | + href="{{ .FeedNextURL }}" | |
| 7 | + hx-get="{{ .FeedNextURL }}" | |
| 8 | + hx-target="#shithub-feed-list" | |
| 9 | + hx-swap="beforeend" | |
| 10 | + hx-select="#shithub-feed-fragment-rows > *" | |
| 11 | + hx-select-oob="#shithub-feed-pagination:outerHTML" | |
| 12 | + hx-indicator="this" | |
| 13 | + > | |
| 14 | + <span class="shithub-feed-more-label">More</span> | |
| 15 | + <span class="shithub-feed-more-loading">Loading...</span> | |
| 16 | + </a> | |
| 17 | +</nav> | |
| 18 | +{{ else }} | |
| 19 | +<nav id="shithub-feed-pagination" class="shithub-feed-pagination" aria-label="Activity pagination"></nav> | |
| 20 | +{{ end }} | |
| 21 | +{{- end }} | |
internal/web/templates/explore/feed_page.htmladded@@ -0,0 +1,6 @@ | ||
| 1 | +{{ define "page" -}} | |
| 2 | +<div id="shithub-feed-fragment-rows"> | |
| 3 | + {{ range .Feed }}{{ template "feed-row" . }}{{ end }} | |
| 4 | +</div> | |
| 5 | +{{ template "explore-feed-pagination" . }} | |
| 6 | +{{- end }} | |
internal/web/templates/explore/index.htmlmodified@@ -97,7 +97,7 @@ | ||
| 97 | 97 | <button type="button" class="shithub-button shithub-button-small" disabled>{{ octicon "list-unordered" }} Filter</button> |
| 98 | 98 | </div> |
| 99 | 99 | {{ if .Feed }} |
| 100 | - <ol class="shithub-feed-list"> | |
| 100 | + <ol id="shithub-feed-list" class="shithub-feed-list" aria-live="polite"> | |
| 101 | 101 | {{ range .Feed }}{{ template "feed-row" . }}{{ end }} |
| 102 | 102 | </ol> |
| 103 | 103 | {{ else }} |
@@ -107,11 +107,7 @@ | ||
| 107 | 107 | {{ if .Viewer.ID }}<a href="/trending" class="shithub-button">Explore trending repositories</a>{{ end }} |
| 108 | 108 | </div> |
| 109 | 109 | {{ end }} |
| 110 | - {{ if .FeedHasNext }} | |
| 111 | - <nav class="shithub-feed-pagination" aria-label="Public activity pagination"> | |
| 112 | - <a class="shithub-button" href="{{ .FeedNextURL }}">More</a> | |
| 113 | - </nav> | |
| 114 | - {{ end }} | |
| 110 | + {{ template "explore-feed-pagination" . }} | |
| 115 | 111 | {{ end }} |
| 116 | 112 | </main> |
| 117 | 113 | |