commit 493de0c9cefe36622516d5f01d0fd758ae6d4561
parent 7b143d9eded945c51dddab1d1df04adbec619e59
Author: David Eisinger <[email protected]>
Date: Sun, 12 Oct 2025 23:54:11 -0400
basic pagination
Diffstat:
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>