diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-15 17:01:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-15 17:01:06 +0200 |
| commit | 459fc93e98f23e326c2fa77fe86ba74c2bae77f0 (patch) | |
| tree | f506a127cc3e0779c4d8cf9f27fe659d81bb787e /packages/meshbay-hub/src/meshbay_hub/static/pager.js | |
| parent | fce0942cb7d81dbdbae70e50b0164fead7e8bc49 (diff) | |
| download | meshbay-459fc93e98f23e326c2fa77fe86ba74c2bae77f0.tar.gz | |
feat(hub): Videos and Music list their cards a page at a time
Previous/next arrows in the pinned toolbar, on group pages and in Search.
Page size is an account preference (Settings → Defaults), 50 by default,
10 to 200 in steps of 10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/pager.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/pager.js | 89 |
1 files changed, 89 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/pager.js b/packages/meshbay-hub/src/meshbay_hub/static/pager.js new file mode 100644 index 0000000..a5c165b --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/pager.js @@ -0,0 +1,89 @@ +import { + html, useState, useRef, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; + +// ── Pages of cards ─────────────────────────────────────────────────────────── +// +// Videos and Music draw one card per film, show or album, and a library of a +// few thousand is a very long scroll. They show a slice at a time instead, +// with arrows in their pinned toolbar. The slice is cut from the list exactly +// as it would have been drawn, so paging never reorders anything. +// +// The size is the reader's own preference, kept on the hub with the others +// (Settings → Defaults). `PAGE_SIZE_PREF` must be in the hub's +// `ALLOWED_PREF_KEYS`, or saving it is a 400 nobody sees. + +export const PAGE_SIZE_PREF = 'media_page_size'; +export const PAGE_SIZE_DEFAULT = 50; +export const PAGE_SIZE_STEP = 10; +export const PAGE_SIZE_MAX = 200; + +// Anything that is not a multiple of the step within range reads as the +// default: the value is a string the hub stores without looking at it. +export function pageSizeFrom(userPrefs) { + const n = Number(userPrefs && userPrefs[PAGE_SIZE_PREF]); + if (!Number.isInteger(n) || n < PAGE_SIZE_STEP || n > PAGE_SIZE_MAX || n % PAGE_SIZE_STEP) { + return PAGE_SIZE_DEFAULT; + } + return n; +} + +// Clamped rather than reset when the list shrinks under the reader — Search +// loses a group's cards while it reconnects — so the page comes back once +// they do. +export function pageBounds(total, size, page) { + const last = Math.max(0, Math.ceil(total / size) - 1); + const current = Math.min(Math.max(0, page), last); + return { page: current, last, start: current * size, end: Math.min(total, (current + 1) * size) }; +} + +// `resetKey` names what the list is (group, mode, filter, query): a different +// list starts on its first page. Compared during render rather than reset by an +// effect, which would draw the old page of the new list for one frame. +export function usePager(total, size, resetKey) { + const key = `${resetKey}|${size}`; + const [state, setState] = useState({ key, page: 0 }); + const bounds = pageBounds(total, size, state.key === key ? state.page : 0); + const setPage = useCallback((page) => { + setState({ key, page }); + }, [key]); + return { ...bounds, total, size, setPage }; +} + +// A new page starts at the top of the list, not wherever the previous one was +// scrolled to. Only ever upwards, and only as far as the list's first row +// sitting under the pinned toolbar, so a reader who has not scrolled is not +// moved. The toolbar is the pager's parent and the list its next sibling — +// the structure style.css's "Sticky chrome" already requires. +function keepListInView(el) { + const toolbar = el && el.parentElement; + const list = toolbar && toolbar.nextElementSibling; + if (!list) return; + const gap = parseFloat(getComputedStyle(toolbar).marginBottom) || 0; + const target = window.scrollY + list.getBoundingClientRect().top + - toolbar.getBoundingClientRect().bottom - gap; + if (target < window.scrollY) window.scrollTo(0, Math.max(0, target)); +} + +export function Pager({ pager }) { + const ref = useRef(null); + if (pager.total <= pager.size) return null; + const go = (page) => { keepListInView(ref.current); pager.setPage(page); }; + return html` + <div class="tb-pager" ref=${ref}> + <button class="tb-btn tb-btn-icon" disabled=${pager.page === 0} + title=${t('pager.previous')} aria-label=${t('pager.previous')} + onClick=${() => go(pager.page - 1)}> + <${Icon} name="chevron-left" /></button> + <span class="tb-pager-range"> + ${t('pager.range', { from: pager.start + 1, to: pager.end, total: pager.total })} + </span> + <button class="tb-btn tb-btn-icon" disabled=${pager.page === pager.last} + title=${t('pager.next')} aria-label=${t('pager.next')} + onClick=${() => go(pager.page + 1)}> + <${Icon} name="chevron-right" /></button> + </div> + `; +} |