marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

commit 493de0c9cefe36622516d5f01d0fd758ae6d4561
parent 7b143d9eded945c51dddab1d1df04adbec619e59
Author: David Eisinger <[email protected]>
Date:   Sun, 12 Oct 2025 23:54:11 -0400

basic pagination

Diffstat:
Minternal/api.go | 41++++++++++++++++++++++++++++++++++++++---
Minternal/database.go | 9+++++++++
Minternal/user_db.go | 9+++++++++
Minternal/web/app.js | 3+++
Minternal/web/styles.css | 6++++++
Minternal/web/user.html | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 133 insertions(+), 3 deletions(-)

diff --git a/internal/api.go b/internal/api.go @@ -414,12 +414,24 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { html = nb } } - // Embed initial results + // Pagination params + q := r.URL.Query() + page := 1 + if pstr := strings.TrimSpace(q.Get("page")); pstr != "" { + if pv, e := strconv.Atoi(pstr); e == nil && pv > 0 { page = pv } + } + size := 20 + if sstr := strings.TrimSpace(q.Get("size")); sstr != "" { + if sv, e := strconv.Atoi(sstr); e == nil && (sv == 20 || sv == 50 || sv == 100) { size = sv } + } + offset := (page-1) * size + + // Embed initial results for the current page var items []Bookmark if isOwnerAuthorized(r, u, db) { - items, err = db.ListBookmarks(r.Context(), u.ID, 50, 0) + items, err = db.ListBookmarks(r.Context(), u.ID, size, offset) } else { - items, err = db.ListPublicBookmarks(r.Context(), u.ID, 50, 0) + items, err = db.ListPublicBookmarks(r.Context(), u.ID, size, offset) } if err == nil { resp := make([]bookmarkResponse, 0, len(items)) @@ -442,6 +454,29 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { } } } + // Embed pagination meta + total := 0 + if isOwnerAuthorized(r, u, db) { + if n, e := db.CountBookmarks(r.Context(), u.ID); e == nil { total = n } + } else { + if n, e := db.CountPublicBookmarks(r.Context(), u.ID); e == nil { total = n } + } + totalPages := 1 + if size > 0 { totalPages = (total + size - 1) / size } + if totalPages < 1 { totalPages = 1 } + pageObj := map[string]any{"page": page, "size": size, "total": totalPages, "username": u.Username, "owner": isOwnerAuthorized(r, u, db)} + if pb, e := json.Marshal(pageObj); e == nil { + pinject := []byte("<script>window.MARKY_PAGINATION=" + string(pb) + "</script>\n") + marker := []byte("<script src=\"/app.js\"></script>") + if i := bytes.Index(html, marker); i >= 0 { + out := make([]byte, 0, len(html)+len(pinject)) + out = append(out, html[:i]...) + out = append(out, pinject...) + out = append(out, html[i:]...) + html = out + } + } + html = applyCacheBust(html) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(html) diff --git a/internal/database.go b/internal/database.go @@ -331,6 +331,15 @@ func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int return out, nil } +// CountBookmarks returns the total number of bookmarks for a user. +func (db *DB) CountBookmarks(ctx context.Context, userID int64) (int, error) { + var n int + if err := db.sql.QueryRowContext(ctx, `SELECT COUNT(1) FROM bookmarks WHERE user_id = ?`, userID).Scan(&n); err != nil { + return 0, fmt.Errorf("count bookmarks: %w", err) + } + return n, nil +} + // UpdateBookmark updates fields for a given bookmark. func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, description *string, public bool) (*Bookmark, error) { if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" { diff --git a/internal/user_db.go b/internal/user_db.go @@ -135,6 +135,15 @@ func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offs return out, nil } +// CountPublicBookmarks returns the total number of public bookmarks for a user. +func (db *DB) CountPublicBookmarks(ctx context.Context, userID int64) (int, error) { + var n int + if err := db.sql.QueryRowContext(ctx, `SELECT COUNT(1) FROM bookmarks WHERE user_id = ? AND public = 1`, userID).Scan(&n); err != nil { + return 0, fmt.Errorf("count public bookmarks: %w", err) + } + return n, nil +} + // GetAPIToken returns the user's API token if set. func (db *DB) GetAPIToken(ctx context.Context, userID int64) (string, bool, error) { var tok sql.NullString diff --git a/internal/web/app.js b/internal/web/app.js @@ -14,6 +14,7 @@ createApp({ disableSignup: !!(window.MARKY_CFG && window.MARKY_CFG.disableSignup), me: null, viewUser: '', + pager: null, auth: { username: '', password: '' }, api: { value: '', busy: false, copied: false, show: false }, bmlet: { copied: false }, @@ -62,6 +63,7 @@ createApp({ const r = Array.isArray(window.MARKY_INITIAL.results) ? window.MARKY_INITIAL.results : []; this.results = this.normalizeResults(r); } + if (window.MARKY_PAGINATION) { this.pager = window.MARKY_PAGINATION; } } catch {} // Do not auto-fetch all bookmarks on initial load } else { @@ -72,6 +74,7 @@ createApp({ const r = Array.isArray(window.MARKY_INITIAL.results) ? window.MARKY_INITIAL.results : []; this.results = this.normalizeResults(r); } + if (window.MARKY_PAGINATION) { this.pager = window.MARKY_PAGINATION; } } catch {} } }, diff --git a/internal/web/styles.css b/internal/web/styles.css @@ -74,3 +74,9 @@ mark { background: #fff3b0; color: inherit; padding: 0 2px; border-radius: 2px; .auth-links { margin-top: 8px; font-size: 13px; } /* Underline all links globally */ a { text-decoration: underline; } + +.pager { margin-top: 16px; font-size: 13px; color: #555; display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; } +.pager .sep { margin: 0 6px; opacity: 0.6; } +.pager .muted { color: #999; text-decoration: none; } +.pager .pages a, .pager .sizes a { margin: 0 4px; } +.pager .sizes > span:first-child { margin-right: 4px; } diff --git a/internal/web/user.html b/internal/web/user.html @@ -77,6 +77,40 @@ </p> </article> </div> + <div class="pager"> + <div v-if="pager && pager.total > 1" class="pages"> + <template v-if="pager.page > 1"> + <a :href="`/u:${me.username}?page=${pager.page-1}&size=${pager.size}`">Prev</a> + </template> + <template v-else> + <a class="muted" :href="`/u:${me.username}?page=${pager.page}&size=${pager.size}`">Prev</a> + </template> + <span class="sep">|</span> + <template v-for="n in pager.total" :key="n"> + <template v-if="n === pager.page"> + <a class="muted" :href="`/u:${me.username}?page=${n}&size=${pager.size}`">{{ n }}</a> + </template> + <template v-else> + <a :href="`/u:${me.username}?page=${n}&size=${pager.size}`">{{ n }}</a> + </template> + </template> + <span class="sep">|</span> + <template v-if="pager.page < pager.total"> + <a :href="`/u:${me.username}?page=${pager.page+1}&size=${pager.size}`">Next</a> + </template> + <template v-else> + <a class="muted" :href="`/u:${me.username}?page=${pager.page}&size=${pager.size}`">Next</a> + </template> + </div> + <div class="sizes"> + <span>Page size:</span> + <a :href="`/u:${me.username}?page=1&size=20`" :class="{ muted: pager && pager.size===20 }">20</a> + <span class="sep">|</span> + <a :href="`/u:${me.username}?page=1&size=50`" :class="{ muted: pager && pager.size===50 }">50</a> + <span class="sep">|</span> + <a :href="`/u:${me.username}?page=1&size=100`" :class="{ muted: pager && pager.size===100 }">100</a> + </div> + </div> </section> <section v-if="view==='public'" class="span2"> @@ -103,6 +137,40 @@ </p> </article> </div> + <div class="pager"> + <div v-if="pager && pager.total > 1" class="pages"> + <template v-if="pager.page > 1"> + <a :href="`/u:${viewUser}?page=${pager.page-1}&size=${pager.size}`">Prev</a> + </template> + <template v-else> + <a class="muted" :href="`/u:${viewUser}?page=${pager.page}&size=${pager.size}`">Prev</a> + </template> + <span class="sep">|</span> + <template v-for="n in pager.total" :key="n"> + <template v-if="n === pager.page"> + <a class="muted" :href="`/u:${viewUser}?page=${n}&size=${pager.size}`">{{ n }}</a> + </template> + <template v-else> + <a :href="`/u:${viewUser}?page=${n}&size=${pager.size}`">{{ n }}</a> + </template> + </template> + <span class="sep">|</span> + <template v-if="pager.page < pager.total"> + <a :href="`/u:${viewUser}?page=${pager.page+1}&size=${pager.size}`">Next</a> + </template> + <template v-else> + <a class="muted" :href="`/u:${viewUser}?page=${pager.page}&size=${pager.size}`">Next</a> + </template> + </div> + <div class="sizes"> + <span>Page size:</span> + <a :href="`/u:${viewUser}?page=1&size=20`" :class="{ muted: pager && pager.size===20 }">20</a> + <span class="sep">|</span> + <a :href="`/u:${viewUser}?page=1&size=50`" :class="{ muted: pager && pager.size===50 }">50</a> + <span class="sep">|</span> + <a :href="`/u:${viewUser}?page=1&size=100`" :class="{ muted: pager && pager.size===100 }">100</a> + </div> + </div> </section> </main> </div>