Skip to content

Commit 0ca2c5b

Browse files
committed
feat(frontend): windowed pagination with ellipsis on downloads page
Artifacts with many builds were rendering every page button from 1 to N, producing an unwieldy bar with dozens of numbered entries. Match the legacy SpongeDownloads behaviour by showing a sliding window of page buttons around the current page with ellipsis gaps to page 1 and the last page. Signed-off-by: Gabriel Harris-Rouquette <gabizou@me.com>
1 parent 293354d commit 0ca2c5b

5 files changed

Lines changed: 278 additions & 27 deletions

File tree

.editorconfig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ indent_size = 2
2020

2121
[*.go]
2222

23+
[*.{gohtml,gocss}]
24+
indent_size = 2
25+
2326

2427
[.editorconfig]
2528
ij_editorconfig_align_group_field_declarations = false

internal/frontend/handlers.go

Lines changed: 119 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,29 @@ type PaginationData struct {
129129
NextOffset int
130130
}
131131

132-
// PageLink is a single pagination page button.
132+
// PageLink is a single pagination page button. Ellipsis entries carry
133+
// Number == 0 and Ellipsis == true; the template renders them as a
134+
// disabled gap between the windowed page numbers and the first/last page.
133135
type PageLink struct {
134-
Number int
135-
Offset int
136-
Active bool
136+
Number int
137+
Offset int
138+
Active bool
139+
Ellipsis bool
137140
}
138141

139-
const buildsPerPage = 10
142+
const (
143+
buildsPerPage = 10
144+
145+
// paginationWindow is the count of numbered page buttons shown around
146+
// the current page. Matches BootstrapVue's default `limit` of 5, which
147+
// is what the legacy SpongeDownloads frontend rendered with
148+
// <b-pagination-nav first-number last-number>.
149+
paginationWindow = 5
150+
151+
// paginationEllipsisThreshold is the smallest window size that still
152+
// shows ellipsis; below this BootstrapVue skips the dots entirely.
153+
paginationEllipsisThreshold = 3
154+
)
140155

141156
func (s *Server) handleDownloads(w http.ResponseWriter, r *http.Request) {
142157
ctx := r.Context()
@@ -550,22 +565,111 @@ func computePagination(offset, total int) PaginationData {
550565
currentPage := (offset / buildsPerPage) + 1
551566
totalPages := (total + buildsPerPage - 1) / buildsPerPage
552567

553-
var pages []PageLink
554-
for i := 1; i <= totalPages; i++ {
555-
pages = append(pages, PageLink{
556-
Number: i,
557-
Offset: (i - 1) * buildsPerPage,
558-
Active: i == currentPage,
559-
})
560-
}
561-
562568
return PaginationData{
563569
CurrentPage: currentPage,
564570
TotalPages: totalPages,
565-
Pages: pages,
571+
Pages: buildPageList(currentPage, totalPages),
566572
HasPrev: currentPage > 1,
567573
HasNext: currentPage < totalPages,
568574
PrevOffset: (currentPage - 2) * buildsPerPage,
569575
NextOffset: currentPage * buildsPerPage,
570576
}
571577
}
578+
579+
// buildPageList ports BootstrapVue's <b-pagination-nav first-number last-number>
580+
// window algorithm (node_modules/bootstrap-vue/src/mixins/pagination.js,
581+
// `paginationParams` + render) so SSR output matches the legacy Vue frontend:
582+
// page 1 and the last page are always shown, with ellipsis gaps around a
583+
// sliding window of paginationWindow numbers centered on currentPage.
584+
func buildPageList(currentPage, totalPages int) []PageLink {
585+
if totalPages <= 0 {
586+
return nil
587+
}
588+
589+
var (
590+
showFirstDots bool
591+
showLastDots bool
592+
numberOfLinks int
593+
startNumber = 1
594+
)
595+
596+
switch {
597+
case totalPages <= paginationWindow:
598+
numberOfLinks = totalPages
599+
case currentPage < paginationWindow-1 && paginationWindow > paginationEllipsisThreshold:
600+
// Near the start: window anchored at 1, ellipsis before the last page.
601+
showLastDots = true
602+
numberOfLinks = paginationWindow
603+
case totalPages-currentPage+2 < paginationWindow && paginationWindow > paginationEllipsisThreshold:
604+
// Near the end: window anchored at totalPages, ellipsis after page 1.
605+
showFirstDots = true
606+
numberOfLinks = paginationWindow
607+
startNumber = totalPages - numberOfLinks + 1
608+
default:
609+
// Middle: current page centered, ellipsis on both sides.
610+
numberOfLinks = paginationWindow - 2
611+
showFirstDots = true
612+
showLastDots = true
613+
startNumber = currentPage - numberOfLinks/2
614+
}
615+
616+
if startNumber < 1 {
617+
startNumber = 1
618+
showFirstDots = false
619+
} else if startNumber > totalPages-numberOfLinks {
620+
startNumber = totalPages - numberOfLinks + 1
621+
showLastDots = false
622+
}
623+
624+
// Collapse a first ellipsis that would sit next to page 1: rendering
625+
// "1 … 3 4 5" wastes a slot, so absorb those pages into the window.
626+
if showFirstDots && startNumber < 4 {
627+
numberOfLinks += 2
628+
startNumber = 1
629+
showFirstDots = false
630+
}
631+
632+
// Same collapse on the right edge.
633+
lastPageNumber := startNumber + numberOfLinks - 1
634+
if showLastDots && lastPageNumber > totalPages-3 {
635+
if lastPageNumber == totalPages-2 {
636+
numberOfLinks += 2
637+
} else {
638+
numberOfLinks += 3
639+
}
640+
showLastDots = false
641+
}
642+
643+
if numberOfLinks > totalPages-startNumber+1 {
644+
numberOfLinks = totalPages - startNumber + 1
645+
}
646+
647+
windowEnd := startNumber + numberOfLinks - 1
648+
649+
pages := make([]PageLink, 0, numberOfLinks+4)
650+
makeLink := func(n int) PageLink {
651+
return PageLink{
652+
Number: n,
653+
Offset: (n - 1) * buildsPerPage,
654+
Active: n == currentPage,
655+
}
656+
}
657+
658+
if startNumber != 1 {
659+
pages = append(pages, makeLink(1))
660+
}
661+
if showFirstDots {
662+
pages = append(pages, PageLink{Ellipsis: true})
663+
}
664+
for n := startNumber; n <= windowEnd; n++ {
665+
pages = append(pages, makeLink(n))
666+
}
667+
if showLastDots {
668+
pages = append(pages, PageLink{Ellipsis: true})
669+
}
670+
if windowEnd != totalPages {
671+
pages = append(pages, makeLink(totalPages))
672+
}
673+
674+
return pages
675+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package frontend
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
// renderPages produces a compact string like "1 2 [3] 4 5 … 20" so test
12+
// expectations read like the buttons a user sees. Active pages are
13+
// bracketed; ellipsis slots render as an ellipsis character.
14+
func renderPages(pages []PageLink) string {
15+
parts := make([]string, 0, len(pages))
16+
for _, p := range pages {
17+
switch {
18+
case p.Ellipsis:
19+
parts = append(parts, "…")
20+
case p.Active:
21+
parts = append(parts, "["+strconv.Itoa(p.Number)+"]")
22+
default:
23+
parts = append(parts, strconv.Itoa(p.Number))
24+
}
25+
}
26+
return strings.Join(parts, " ")
27+
}
28+
29+
func TestBuildPageList(t *testing.T) {
30+
// Expected outputs cross-referenced against BootstrapVue's
31+
// <b-pagination-nav first-number last-number> with default limit=5.
32+
cases := []struct {
33+
name string
34+
currentPage int
35+
totalPages int
36+
want string
37+
}{
38+
{"empty", 1, 0, ""},
39+
{"single", 1, 1, "[1]"},
40+
{"two pages", 1, 2, "[1] 2"},
41+
{"five pages current 1", 1, 5, "[1] 2 3 4 5"},
42+
{"five pages current 3", 3, 5, "1 2 [3] 4 5"},
43+
{"six pages current 1", 1, 6, "[1] 2 3 4 5 6"},
44+
{"six pages current 2", 2, 6, "1 [2] 3 4 5 6"},
45+
{"six pages current 3", 3, 6, "1 2 [3] 4 5 6"},
46+
{"six pages current 4", 4, 6, "1 2 3 [4] 5 6"},
47+
{"six pages current 5", 5, 6, "1 2 3 4 [5] 6"},
48+
{"six pages current 6", 6, 6, "1 2 3 4 5 [6]"},
49+
{"ten pages current 1 ellipsis right", 1, 10, "[1] 2 3 4 5 … 10"},
50+
{"ten pages current 3 ellipsis right", 3, 10, "1 2 [3] 4 5 … 10"},
51+
{"ten pages current 4 collapses left", 4, 10, "1 2 3 [4] 5 … 10"},
52+
{"ten pages current 5 both sides", 5, 10, "1 … 4 [5] 6 … 10"},
53+
{"ten pages current 6 both sides", 6, 10, "1 … 5 [6] 7 … 10"},
54+
{"ten pages current 7 collapses right", 7, 10, "1 … 6 [7] 8 9 10"},
55+
{"ten pages current 8 near end", 8, 10, "1 … 6 7 [8] 9 10"},
56+
{"ten pages current 10 near end", 10, 10, "1 … 6 7 8 9 [10]"},
57+
{"twenty pages current 1", 1, 20, "[1] 2 3 4 5 … 20"},
58+
{"twenty pages current 10 middle", 10, 20, "1 … 9 [10] 11 … 20"},
59+
{"twenty pages current 17 collapses right", 17, 20, "1 … 16 [17] 18 19 20"},
60+
{"twenty pages current 20", 20, 20, "1 … 16 17 18 19 [20]"},
61+
}
62+
63+
for _, tc := range cases {
64+
t.Run(tc.name, func(t *testing.T) {
65+
got := renderPages(buildPageList(tc.currentPage, tc.totalPages))
66+
assert.Equal(t, tc.want, got)
67+
})
68+
}
69+
}
70+
71+
func TestComputePagination(t *testing.T) {
72+
t.Run("zero total returns empty", func(t *testing.T) {
73+
p := computePagination(0, 0)
74+
assert.Equal(t, 0, p.TotalPages)
75+
assert.Empty(t, p.Pages)
76+
assert.False(t, p.HasPrev)
77+
assert.False(t, p.HasNext)
78+
})
79+
80+
t.Run("offset drives current page", func(t *testing.T) {
81+
p := computePagination(40, 200) // page 5 of 20
82+
assert.Equal(t, 5, p.CurrentPage)
83+
assert.Equal(t, 20, p.TotalPages)
84+
assert.Equal(t, "1 … 4 [5] 6 … 20", renderPages(p.Pages))
85+
assert.True(t, p.HasPrev)
86+
assert.True(t, p.HasNext)
87+
assert.Equal(t, 30, p.PrevOffset)
88+
assert.Equal(t, 50, p.NextOffset)
89+
})
90+
91+
t.Run("first page offsets", func(t *testing.T) {
92+
p := computePagination(0, 200)
93+
assert.False(t, p.HasPrev)
94+
assert.True(t, p.HasNext)
95+
assert.Equal(t, 10, p.NextOffset)
96+
})
97+
98+
t.Run("last page offsets", func(t *testing.T) {
99+
p := computePagination(190, 200)
100+
assert.True(t, p.HasPrev)
101+
assert.False(t, p.HasNext)
102+
assert.Equal(t, 180, p.PrevOffset)
103+
})
104+
105+
t.Run("page link offsets use buildsPerPage", func(t *testing.T) {
106+
p := computePagination(0, 100)
107+
for _, link := range p.Pages {
108+
if link.Ellipsis {
109+
continue
110+
}
111+
assert.Equal(t, (link.Number-1)*buildsPerPage, link.Offset)
112+
}
113+
})
114+
}

internal/frontend/static/css/main.css

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@
2626
--clr-api: #009172;
2727

2828
/* Semantic */
29-
--clr-success: #18bc9c;
30-
--clr-warning: #f39c12;
31-
--clr-danger: #e74c3c;
32-
--clr-link: #18bc9c;
33-
--clr-link-dk: #0f7864;
29+
--clr-success: #18bc9c;
30+
--clr-success-lt: #3be6c4;
31+
--clr-warning: #f39c12;
32+
--clr-danger: #e74c3c;
33+
--clr-link: #18bc9c;
34+
--clr-link-dk: #0f7864;
3435

3536
/* Type */
3637
--ff-heading: 'Montserrat', 'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -341,21 +342,44 @@ footer { background: var(--grey-750); color: #fff; text-align: center; font-size
341342
@media (max-width: 768px) { #sponsor img { height: 50px; } }
342343

343344
/* ── Pagination ──────────────────────────────────────────────────────── */
345+
/* Base rules match bootswatch-flatly (teal) for any pagination outside the
346+
downloads `.navigation` wrapper; the `.navigation`-scoped overrides below
347+
reproduce the legacy _downloads.scss grey treatment that wins specificity
348+
on the downloads page. */
344349
.navigation { margin-bottom: 20px; display: flex; justify-content: center; }
345350
.pagination { list-style: none; display: flex; padding: 0; margin: 0; }
346-
.page-item { margin-right: 2px; }
347351

348352
.page-link {
349-
display: block; padding: 0.5rem 0.75rem; line-height: 1.25;
350-
color: #fff; background: var(--clr-success); border: 0; text-decoration: none;
353+
position: relative; display: block; padding: 0.5rem 0.75rem;
354+
margin-left: 0; line-height: 1.25;
355+
color: #fff; background: var(--clr-success);
356+
border: 0 solid transparent; text-decoration: none;
357+
}
358+
.page-link:hover {
359+
z-index: 2; color: #fff; background: var(--clr-link-dk);
360+
border-color: transparent; text-decoration: none;
361+
}
362+
.page-item:first-child .page-link {
363+
border-top-left-radius: 0.25rem; border-bottom-left-radius: 0.25rem;
364+
}
365+
.page-item:last-child .page-link {
366+
border-top-right-radius: 0.25rem; border-bottom-right-radius: 0.25rem;
367+
}
368+
.page-item.active .page-link {
369+
z-index: 3; color: #fff; background: var(--clr-link-dk); border-color: transparent;
370+
}
371+
.page-item.disabled .page-link {
372+
color: var(--grey-150); cursor: auto; pointer-events: none;
373+
background: var(--clr-success-lt); border-color: transparent;
351374
}
352-
.page-link:hover { color: #fff; background: var(--clr-link-dk); text-decoration: none; }
353-
.page-item.active .page-link { background: var(--clr-link-dk); }
354375

355-
/* Downloads page overrides pagination to grey */
376+
/* Downloads page: greyscale pagination (mirrors legacy `.navigation .page-item` SCSS).
377+
$secondary = #999 (--grey-600), $primary = #333 (--grey-800).
378+
Ellipsis/disabled cells use tint($secondary, 20%) = #adadad. */
356379
.navigation .page-item > a.page-link { background: var(--grey-600); }
357-
.navigation .page-item > a.page-link:hover { background: #6b6b6b; }
380+
.navigation .page-item > a.page-link:hover { background: #6b6b6b; } /* shade($secondary, 30%) */
358381
.navigation .page-item.active > .page-link { background: var(--grey-800); }
382+
.navigation .page-item.disabled > .page-link { color: #fff; background: #adadad; }
359383

360384
/* ── Section Headings ────────────────────────────────────────────────── */
361385
#all-builds h3 { margin-top: 1.5rem; margin-bottom: 1rem; }

internal/frontend/templates/downloads.gohtml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@
180180
</li>
181181
{{end}}
182182
{{range .Pagination.Pages}}
183+
{{if .Ellipsis}}
184+
<li class="page-item disabled" aria-disabled="true" role="separator">
185+
<span class="page-link">&hellip;</span>
186+
</li>
187+
{{else}}
183188
<li class="page-item {{if .Active}}active{{end}}">
184189
{{if .Active}}
185190
<span class="page-link">{{.Number}}</span>
@@ -188,6 +193,7 @@
188193
{{end}}
189194
</li>
190195
{{end}}
196+
{{end}}
191197
{{if .Pagination.HasNext}}
192198
<li class="page-item">
193199
<a href="?minecraft={{$.Page.SelectedMC}}&offset={{.Pagination.NextOffset}}" class="page-link">Older &raquo;</a>

0 commit comments

Comments
 (0)