feat(board): default pagination with optional infinite scroll and URL-based page routing

This commit is contained in:
plebeius
2026-02-20 20:16:11 +08:00
parent f660d1a814
commit 60d2f6804d
51 changed files with 775 additions and 82 deletions
@@ -0,0 +1,36 @@
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0 16px;
flex-wrap: wrap;
}
.paginationButton {
min-width: 32px;
padding: 4px 8px;
font-size: 14px;
cursor: pointer;
text-decoration: var(--button-text-decoration);
color: var(--button-desktop-text-color);
background: transparent;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
}
.paginationButton:hover:not(:disabled) {
color: var(--button-desktop-text-color-hover);
cursor: pointer;
}
.paginationButton:disabled,
.paginationButton.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pageButtonActive {
font-weight: bold;
color: var(--button-desktop-text-color-hover);
}
@@ -0,0 +1,61 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import styles from './board-pagination.module.css';
export interface BoardPaginationProps {
basePath: string;
currentPage: number;
totalPages: number;
}
const BoardPagination = ({ basePath, currentPage, totalPages }: BoardPaginationProps) => {
const { t } = useTranslation();
const pageHref = (page: number) => (page === 1 ? basePath : `${basePath}/${page}`);
const prevHref = currentPage > 1 ? pageHref(currentPage - 1) : undefined;
const nextHref = currentPage < totalPages ? pageHref(currentPage + 1) : undefined;
if (totalPages <= 1) {
return null;
}
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
return (
<div className={styles.pagination}>
{prevHref ? (
<Link to={prevHref} className={styles.paginationButton} aria-label={t('prev')}>
{t('prev')}
</Link>
) : (
<span className={`${styles.paginationButton} ${styles.disabled}`} aria-disabled>
{t('prev')}
</span>
)}
{pageNumbers.map((page) => {
const href = pageHref(page);
const isCurrent = page === currentPage;
return isCurrent ? (
<span key={page} className={`${styles.paginationButton} ${styles.pageButtonActive}`} aria-label={`Page ${page} (current)`} aria-current='page'>
{page}
</span>
) : (
<Link key={page} to={href} className={styles.paginationButton} aria-label={`Go to page ${page}`}>
{page}
</Link>
);
})}
{nextHref ? (
<Link to={nextHref} className={styles.paginationButton} aria-label={t('next')}>
{t('next')}
</Link>
) : (
<span className={`${styles.paginationButton} ${styles.disabled}`} aria-disabled>
{t('next')}
</span>
)}
</div>
);
};
export default BoardPagination;
+2
View File
@@ -0,0 +1,2 @@
export { default } from './board-pagination';
export type { BoardPaginationProps } from './board-pagination';
@@ -0,0 +1,102 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import InterfaceSettings from '../interface-settings';
import useFeedViewSettingsStore from '../../../../stores/use-feed-view-settings-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'en' },
}),
}));
vi.mock('../../../../hooks/use-theme', () => ({
default: () => ['yotsuba', vi.fn()],
}));
vi.mock('../../../../stores/use-expanded-media-store', () => ({
default: () => ({ fitExpandedImagesToScreen: false, setFitExpandedImagesToScreen: vi.fn() }),
}));
vi.mock('../../../../stores/use-special-theme-store', () => ({
default: () => ({ isEnabled: false, setIsEnabled: vi.fn() }),
}));
vi.mock('../../../../lib/utils/time-utils', () => ({
isChristmas: () => false,
}));
vi.mock('../../version', () => ({
default: () => null,
}));
/** Minimal component that subscribes to feed view settings (like Board) to verify re-renders. */
const BoardModeIndicator = () => {
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
return <span data-testid='board-mode'>{enableInfiniteScroll ? 'infinite' : 'pagination'}</span>;
};
const STORAGE_KEY = 'feed-view-settings-store';
let root: Root;
let container: HTMLDivElement;
const render = (children: React.ReactNode) => {
act(() => {
root.render(createElement(MemoryRouter, {}, children));
});
};
describe('InterfaceSettings', () => {
let setItemSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
localStorage.removeItem(STORAGE_KEY);
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
setItemSpy.mockRestore();
});
it('renders enable_infinite_scroll checkbox unchecked by default', () => {
render(createElement(InterfaceSettings));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
expect(label).toBeTruthy();
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
expect(checkbox?.checked).toBe(false);
});
it('toggling checkbox updates persisted state and re-renders board mode', async () => {
render(createElement(React.Fragment, {}, createElement(InterfaceSettings), createElement(BoardModeIndicator)));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('pagination');
await act(async () => {
checkbox?.click();
});
expect(useFeedViewSettingsStore.getState().enableInfiniteScroll).toBe(true);
expect(checkbox?.checked).toBe(true);
expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('infinite');
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"enableInfiniteScroll":true'));
});
});
@@ -5,6 +5,7 @@ import packageJson from '../../../../package.json';
import styles from './interface-settings.module.css';
import capitalize from 'lodash/capitalize';
import useExpandedMediaStore from '../../../stores/use-expanded-media-store';
import useFeedViewSettingsStore from '../../../stores/use-feed-view-settings-store';
import useSpecialThemeStore from '../../../stores/use-special-theme-store';
import { isChristmas } from '../../../lib/utils/time-utils';
import Version from '../../version';
@@ -124,6 +125,7 @@ const InterfaceLanguage = () => {
const InterfaceSettings = () => {
const { t } = useTranslation();
const { fitExpandedImagesToScreen, setFitExpandedImagesToScreen } = useExpandedMediaStore();
const { enableInfiniteScroll, setEnableInfiniteScroll } = useFeedViewSettingsStore();
return (
<div className={styles.interfaceSettings}>
@@ -146,6 +148,12 @@ const InterfaceSettings = () => {
</label>
<div className={styles.settingTip}>{capitalize(t('fit_expanded_images_to_screen_tip'))}</div>
</div>
<div className={styles.setting}>
<label>
<input type='checkbox' checked={enableInfiniteScroll} onChange={(e) => setEnableInfiniteScroll(e.target.checked)} />
{capitalize(t('enable_infinite_scroll'))}
</label>
</div>
</div>
);
};