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,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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './board-pagination';
|
||||
export type { BoardPaginationProps } from './board-pagination';
|
||||
+102
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user