mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(board): default pagination with optional infinite scroll and URL-based page routing
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeGuiPostsPerPage, getBoardFeedPageSizeConstants, getPageSlice } from '../board-feed-pagination';
|
||||
|
||||
describe('computeGuiPostsPerPage', () => {
|
||||
it('uses community.features.postsPerPage when valid numeric > 0', () => {
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: 15 } })).toBe(15);
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: 20 } })).toBe(20);
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: 1 } })).toBe(1);
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: 100 } })).toBe(100);
|
||||
});
|
||||
|
||||
it('falls back to 15 when postsPerPage is missing, invalid, or non-positive', () => {
|
||||
expect(computeGuiPostsPerPage(undefined)).toBe(15);
|
||||
expect(computeGuiPostsPerPage(null)).toBe(15);
|
||||
expect(computeGuiPostsPerPage({})).toBe(15);
|
||||
expect(computeGuiPostsPerPage({ features: {} })).toBe(15);
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: 0 } })).toBe(15);
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: -1 } })).toBe(15);
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: NaN } })).toBe(15);
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: Infinity } })).toBe(15);
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: '15' as unknown as number } })).toBe(15);
|
||||
expect(computeGuiPostsPerPage({ features: { postsPerPage: 15.7 } })).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBoardFeedPageSizeConstants', () => {
|
||||
it('directory page size 15 -> pagination feed size 150, 10-page cap', () => {
|
||||
const c = getBoardFeedPageSizeConstants(15);
|
||||
expect(c.guiPostsPerPage).toBe(15);
|
||||
expect(c.maxGuiPages).toBe(10);
|
||||
expect(c.paginationFeedPostsPerPage).toBe(150);
|
||||
expect(c.infiniteFeedPostsPerPage).toBe(15);
|
||||
});
|
||||
|
||||
it('directory page size 20 -> pagination feed size 200', () => {
|
||||
const c = getBoardFeedPageSizeConstants(20);
|
||||
expect(c.guiPostsPerPage).toBe(20);
|
||||
expect(c.maxGuiPages).toBe(10);
|
||||
expect(c.paginationFeedPostsPerPage).toBe(200);
|
||||
expect(c.infiniteFeedPostsPerPage).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPageSlice', () => {
|
||||
const guiPostsPerPage = 15;
|
||||
const maxGuiPages = 10;
|
||||
|
||||
it('returns correct slice for page 1', () => {
|
||||
const items = Array.from({ length: 200 }, (_, i) => i);
|
||||
const slice = getPageSlice(items, 1, guiPostsPerPage, maxGuiPages);
|
||||
expect(slice).toHaveLength(15);
|
||||
expect(slice[0]).toBe(0);
|
||||
expect(slice[14]).toBe(14);
|
||||
});
|
||||
|
||||
it('returns correct slice for middle page', () => {
|
||||
const items = Array.from({ length: 200 }, (_, i) => i);
|
||||
const slice = getPageSlice(items, 5, guiPostsPerPage, maxGuiPages);
|
||||
expect(slice).toHaveLength(15);
|
||||
expect(slice[0]).toBe(60);
|
||||
expect(slice[14]).toBe(74);
|
||||
});
|
||||
|
||||
it('returns correct slice for last page', () => {
|
||||
const items = Array.from({ length: 200 }, (_, i) => i);
|
||||
const slice = getPageSlice(items, 10, guiPostsPerPage, maxGuiPages);
|
||||
expect(slice).toHaveLength(15);
|
||||
expect(slice[0]).toBe(135);
|
||||
expect(slice[14]).toBe(149);
|
||||
});
|
||||
|
||||
it('returns partial slice when total items do not fill last page', () => {
|
||||
const items = Array.from({ length: 37 }, (_, i) => i);
|
||||
const slice = getPageSlice(items, 3, guiPostsPerPage, maxGuiPages);
|
||||
expect(slice).toHaveLength(7);
|
||||
expect(slice[0]).toBe(30);
|
||||
expect(slice[6]).toBe(36);
|
||||
});
|
||||
|
||||
it('clamps page when total items shrink (requested page beyond valid range)', () => {
|
||||
const items = Array.from({ length: 20 }, (_, i) => i); // only 2 pages of 15
|
||||
const slice = getPageSlice(items, 10, guiPostsPerPage, maxGuiPages);
|
||||
expect(slice).toHaveLength(5); // page 2 has 5 items (15-19)
|
||||
expect(slice[0]).toBe(15);
|
||||
expect(slice[4]).toBe(19);
|
||||
});
|
||||
|
||||
it('clamps page 1 when requesting page 0 or negative', () => {
|
||||
const items = Array.from({ length: 50 }, (_, i) => i);
|
||||
const slice0 = getPageSlice(items, 0, guiPostsPerPage, maxGuiPages);
|
||||
const sliceNeg = getPageSlice(items, -1, guiPostsPerPage, maxGuiPages);
|
||||
expect(slice0).toEqual(getPageSlice(items, 1, guiPostsPerPage, maxGuiPages));
|
||||
expect(sliceNeg).toEqual(getPageSlice(items, 1, guiPostsPerPage, maxGuiPages));
|
||||
});
|
||||
|
||||
it('returns empty array when guiPostsPerPage or maxGuiPages is 0', () => {
|
||||
const items = [1, 2, 3];
|
||||
expect(getPageSlice(items, 1, 0, 10)).toEqual([]);
|
||||
expect(getPageSlice(items, 1, 15, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when items is empty', () => {
|
||||
expect(getPageSlice([], 1, guiPostsPerPage, maxGuiPages)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
const GUI_POSTS_PER_PAGE_FALLBACK = 15;
|
||||
const MAX_GUI_PAGES = 10;
|
||||
|
||||
/** Minimal shape for directory community with optional postsPerPage */
|
||||
export interface CommunityWithPostsPerPage {
|
||||
features?: { postsPerPage?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute posts-per-page for the GUI from directory features.
|
||||
* Single directory board: uses community.features.postsPerPage when valid numeric > 0.
|
||||
* Missing metadata, non-directory board, /all, /subs, /mod: fallback 15.
|
||||
*/
|
||||
export const computeGuiPostsPerPage = (community?: CommunityWithPostsPerPage | null): number => {
|
||||
const value = community?.features?.postsPerPage;
|
||||
if (typeof value === 'number' && value > 0 && Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
return GUI_POSTS_PER_PAGE_FALLBACK;
|
||||
};
|
||||
|
||||
/**
|
||||
* Board feed pagination constants.
|
||||
* Fallback 15 and max 10 GUI pages are fixed.
|
||||
*/
|
||||
export const getBoardFeedPageSizeConstants = (guiPostsPerPage: number) => ({
|
||||
guiPostsPerPage,
|
||||
maxGuiPages: MAX_GUI_PAGES,
|
||||
paginationFeedPostsPerPage: guiPostsPerPage * MAX_GUI_PAGES,
|
||||
infiniteFeedPostsPerPage: guiPostsPerPage,
|
||||
});
|
||||
|
||||
/**
|
||||
* Slice items for a given page with page clamping.
|
||||
* When total items shrink, the requested page is clamped to the last valid page.
|
||||
*/
|
||||
export const getPageSlice = <T>(items: T[], page: number, guiPostsPerPage: number, maxGuiPages: number): T[] => {
|
||||
if (guiPostsPerPage <= 0 || maxGuiPages <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const totalItems = items.length;
|
||||
const totalGuiPages = Math.min(maxGuiPages, Math.ceil(totalItems / guiPostsPerPage) || 1);
|
||||
const clampedPage = Math.max(1, Math.min(page, totalGuiPages));
|
||||
const start = (clampedPage - 1) * guiPostsPerPage;
|
||||
const end = start + guiPostsPerPage;
|
||||
|
||||
return items.slice(start, end);
|
||||
};
|
||||
@@ -118,8 +118,10 @@ export const isFeedRoute = (pathname: string): boolean => {
|
||||
if (segments.length >= 1) {
|
||||
if (segments.length === 1) return true;
|
||||
if (segments.length === 2 && segments[1] === 'catalog') return true;
|
||||
if (segments.length === 2 && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1])) return true;
|
||||
if (segments.length === 2 && (/^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1]) || /^([1-9]|10)$/.test(segments[1]))) return true;
|
||||
if (segments.length === 3 && segments[1] === 'catalog' && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[2])) return true;
|
||||
if (segments.length === 3 && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1]) && /^([1-9]|10)$/.test(segments[2])) return true;
|
||||
if (segments.length === 2 && segments[0] !== 'all' && segments[0] !== 'subs' && segments[0] !== 'mod' && /^([1-9]|10)$/.test(segments[1])) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -143,6 +145,32 @@ export const isModQueueRoute = (pathname: string): boolean => {
|
||||
return normalizedPath.includes('/modqueue');
|
||||
};
|
||||
|
||||
/** Page numbers 1–10 for board feed pagination */
|
||||
export const BOARD_PAGE_REGEX = /^([1-9]|10)$/;
|
||||
|
||||
export const isBoardFeedPageNumber = (segment: string): boolean => BOARD_PAGE_REGEX.test(segment);
|
||||
|
||||
/** Strip trailing page number (1–10) from path for feed cache key */
|
||||
export const stripPageFromFeedPath = (path: string): string => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.length > 1 && isBoardFeedPageNumber(segments[segments.length - 1])) {
|
||||
return '/' + segments.slice(0, -1).join('/');
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
/** Parse current page number (1–10) from feed pathname; returns 1 if none */
|
||||
export const getPageFromFeedPath = (pathname: string): number => {
|
||||
const normalized = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && isBoardFeedPageNumber(last)) {
|
||||
const n = parseInt(last, 10);
|
||||
return Math.min(10, Math.max(1, n));
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
export const getFeedCacheKey = (pathname: string): string | null => {
|
||||
let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
normalizedPath = normalizedPath.replace(/\/settings$/, '');
|
||||
@@ -161,7 +189,7 @@ export const getFeedCacheKey = (pathname: string): string | null => {
|
||||
}
|
||||
|
||||
if (isFeedRoute(pathname)) {
|
||||
return normalizedPath;
|
||||
return stripPageFromFeedPath(normalizedPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user