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`