first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
import React, {ReactElement, useEffect} from 'react';
import {useListbox} from '../../forms/listbox/use-listbox';
import {Listbox} from '../../forms/listbox/listbox';
import {Menu} from './menu-trigger';
import {useListboxKeyboardNavigation} from '../../forms/listbox/use-listbox-keyboard-navigation';
import {useTypeSelect} from '../../forms/listbox/use-type-select';
import {ListBoxChildren, ListboxProps} from '../../forms/listbox/types';
import {VirtualElement} from '@floating-ui/react-dom';
const preventContextOnMenu = (e: MouseEvent) => {
e.preventDefault();
};
type Props = ListboxProps &
ListBoxChildren<any> & {
position?: {x: number; y: number} | null;
};
export function ContextMenu({position, children, ...props}: Props) {
const listbox = useListbox({
...props,
isOpen: props.isOpen && !!position,
placement: 'right-start',
floatingWidth: 'auto',
role: 'menu',
loopFocus: true,
children:
(children as ReactElement)?.type === Menu
? (children as ReactElement).props.children
: children,
});
const {
reference,
refs,
state: {isOpen, setIsOpen, activeIndex},
focusItem,
listContent,
} = listbox;
useEffect(() => {
if (refs.floating.current) {
refs.floating.current.addEventListener(
'contextmenu',
preventContextOnMenu,
);
return () => {
refs.floating.current?.removeEventListener(
'contextmenu',
preventContextOnMenu,
);
};
}
}, [refs.floating]);
useEffect(() => {
if (position) {
reference(pointToVirtualElement(position));
setIsOpen(true);
} else {
setIsOpen(false);
}
}, [position, reference, setIsOpen]);
const {handleListboxKeyboardNavigation} =
useListboxKeyboardNavigation(listbox);
const {findMatchingItem} = useTypeSelect();
return (
<Listbox
listbox={listbox}
onKeyDownCapture={e => {
if (!isOpen) return;
const i = findMatchingItem(e, listContent, activeIndex);
if (i) {
focusItem('increment', i);
}
}}
onKeyDown={handleListboxKeyboardNavigation}
/>
);
}
export function pointToVirtualElement(
{x, y}: {x: number; y: number},
contextElement?: Element,
): VirtualElement {
return {
getBoundingClientRect() {
return {
x,
y,
width: 0,
height: 0,
top: y,
right: x,
bottom: y,
left: x,
};
},
contextElement,
};
}

View File

@@ -0,0 +1,129 @@
import React, {cloneElement, forwardRef, ReactElement, useId} from 'react';
import {useListbox} from '@common/ui/forms/listbox/use-listbox';
import {Item} from '@common/ui/forms/listbox/item';
import {Section} from '@common/ui/forms/listbox/section';
import {Listbox} from '@common/ui/forms/listbox/listbox';
import {useListboxKeyboardNavigation} from '@common/ui/forms/listbox/use-listbox-keyboard-navigation';
import {createEventHandler} from '@common/utils/dom/create-event-handler';
import {useTypeSelect} from '@common/ui/forms/listbox/use-type-select';
import {ListBoxChildren, ListboxProps} from '@common/ui/forms/listbox/types';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
import {SearchIcon} from '@common/icons/material/Search';
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
type Props = ListboxProps & {
searchPlaceholder?: string;
showSearchField?: boolean;
children: [ReactElement, ReactElement<ListBoxChildren<string | number>>];
};
export const MenuTrigger = forwardRef<HTMLButtonElement, Props>(
(props, ref) => {
const {
searchPlaceholder,
showSearchField,
children: [menuTrigger, menu],
floatingWidth = 'auto',
isLoading,
} = props;
const id = useId();
const isMobile = useIsMobileMediaQuery();
const listbox = useListbox(
{
...props,
clearInputOnItemSelection: true,
showEmptyMessage: showSearchField,
// on mobile menu will be shown as bottom drawer, so make it fullscreen width always
floatingWidth: isMobile ? 'auto' : floatingWidth,
virtualFocus: showSearchField,
role: showSearchField ? 'listbox' : 'menu',
loopFocus: !showSearchField,
children: menu.props.children,
},
ref,
);
const {
state: {isOpen, setIsOpen, activeIndex, inputValue, setInputValue},
listboxId,
focusItem,
listContent,
reference,
onInputChange,
} = listbox;
const {
handleTriggerKeyDown,
handleListboxKeyboardNavigation,
handleListboxSearchFieldKeydown,
} = useListboxKeyboardNavigation(listbox);
const {findMatchingItem} = useTypeSelect();
// focus matching item when user types, if dropdown is open
const handleListboxTypeSelect = (e: React.KeyboardEvent) => {
if (!isOpen) return;
const i = findMatchingItem(e, listContent, activeIndex);
if (i != null) {
focusItem('increment', i);
}
};
return (
<Listbox
onClick={e => e.stopPropagation()}
listbox={listbox}
onKeyDownCapture={
!showSearchField ? handleListboxTypeSelect : undefined
}
onKeyDown={handleListboxKeyboardNavigation}
onClose={showSearchField ? () => setInputValue('') : undefined}
aria-labelledby={id}
isLoading={isLoading}
searchField={
showSearchField ? (
<TextField
size="sm"
placeholder={searchPlaceholder}
startAdornment={<SearchIcon />}
className="flex-shrink-0 px-8 pb-8 pt-4"
autoFocus
aria-expanded={isOpen ? 'true' : 'false'}
aria-haspopup="listbox"
aria-controls={isOpen ? listboxId : undefined}
aria-autocomplete="list"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
value={inputValue}
onChange={onInputChange}
onKeyDown={e => {
handleListboxSearchFieldKeydown(e);
}}
/>
) : null
}
>
{cloneElement(menuTrigger, {
id,
'aria-expanded': isOpen ? 'true' : 'false',
'aria-haspopup': 'menu',
'aria-controls': isOpen ? listboxId : undefined,
ref: reference,
onKeyDown: handleTriggerKeyDown,
onClick: createEventHandler(e => {
menuTrigger.props?.onClick?.(e);
setIsOpen(!isOpen);
}),
})}
</Listbox>
);
},
);
export function Menu({children}: ListBoxChildren<string | number>) {
return children as unknown as ReactElement;
}
export {Item as MenuItem};
export {Section as MenuSection};

View File

@@ -0,0 +1,54 @@
import {useTrans} from '@common/i18n/use-trans';
import {useSettings} from '@common/core/settings/use-settings';
import {Link} from 'react-router-dom';
import {NavbarProps} from '@common/ui/navigation/navbar/navbar';
import clsx from 'clsx';
interface LogoProps {
color?: NavbarProps['color'];
logoColor?: NavbarProps['logoColor'];
isDarkMode?: boolean;
className?: string;
}
export function Logo({color, logoColor, isDarkMode, className}: LogoProps) {
const {trans} = useTrans();
const {branding} = useSettings();
let desktopLogo: string;
let mobileLogo: string;
if (
isDarkMode ||
!branding.logo_dark ||
(logoColor !== 'dark' && color !== 'bg' && color !== 'bg-alt')
) {
desktopLogo = branding.logo_light;
mobileLogo = branding.logo_light_mobile;
} else {
desktopLogo = branding.logo_dark;
mobileLogo = branding.logo_dark_mobile;
}
if (!mobileLogo && !desktopLogo) {
return null;
}
return (
<Link
to="/"
className={clsx(
'mr-4 block h-full max-h-26 flex-shrink-0 md:mr-24 md:max-h-36',
className,
)}
aria-label={trans({message: 'Go to homepage'})}
>
<picture>
<source srcSet={mobileLogo || desktopLogo} media="(max-width: 768px)" />
<source srcSet={desktopLogo} media="(min-width: 768px)" />
<img
className="block h-full max-h-26 w-auto md:max-h-36"
alt={trans({message: 'Site logo'})}
/>
</picture>
</Link>
);
}

View File

@@ -0,0 +1,83 @@
import {ButtonColor} from '@common/ui/buttons/get-shared-button-style';
import {useSettings} from '@common/core/settings/use-settings';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {Menu, MenuTrigger} from '@common/ui/navigation/menu/menu-trigger';
import {IconButton} from '@common/ui/buttons/icon-button';
import {PersonIcon} from '@common/icons/material/Person';
import {Item} from '@common/ui/forms/listbox/item';
import {Trans} from '@common/i18n/trans';
import {Link} from 'react-router-dom';
import {Button} from '@common/ui/buttons/button';
import {NavbarProps} from '@common/ui/navigation/navbar/navbar';
import {Fragment} from 'react';
interface NavbarAuthButtonsProps {
primaryButtonColor?: ButtonColor;
navbarColor?: NavbarProps['color'];
}
export function NavbarAuthButtons({
primaryButtonColor,
navbarColor,
}: NavbarAuthButtonsProps) {
if (!primaryButtonColor) {
primaryButtonColor = navbarColor === 'primary' ? 'paper' : 'primary';
}
return (
<Fragment>
<MobileButtons />
<DesktopButtons primaryButtonColor={primaryButtonColor} />
</Fragment>
);
}
interface DesktopButtonsProps {
primaryButtonColor: ButtonColor;
}
function DesktopButtons({primaryButtonColor}: DesktopButtonsProps) {
const {registration} = useSettings();
return (
<div className="text-sm max-md:hidden">
{!registration.disable && (
<Button
elementType={Link}
to="/register"
variant="text"
className="mr-10"
>
<Trans message="Register" />
</Button>
)}
<Button
elementType={Link}
to="/login"
variant="raised"
color={primaryButtonColor}
>
<Trans message="Login" />
</Button>
</div>
);
}
function MobileButtons() {
const {registration} = useSettings();
const navigate = useNavigate();
return (
<MenuTrigger>
<IconButton size="md" className="md:hidden">
<PersonIcon />
</IconButton>
<Menu>
<Item value="login" onSelected={() => navigate('/login')}>
<Trans message="Login" />
</Item>
{!registration.disable && (
<Item value="register" onSelected={() => navigate('/register')}>
<Trans message="Register" />
</Item>
)}
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,137 @@
import {ReactElement, useContext} from 'react';
import {ListboxItemProps} from '@common/ui/forms/listbox/item';
import {SiteConfigContext} from '@common/core/settings/site-config-context';
import {useLogout} from '@common/auth/requests/logout';
import {useCustomMenu} from '@common/menus/use-custom-menu';
import {useSettings} from '@common/core/settings/use-settings';
import {useAuth} from '@common/auth/use-auth';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {useThemeSelector} from '@common/ui/themes/theme-selector-context';
import {
Menu,
MenuItem,
MenuTrigger,
} from '@common/ui/navigation/menu/menu-trigger';
import {NotificationsIcon} from '@common/icons/material/Notifications';
import {Trans} from '@common/i18n/trans';
import {PaymentsIcon} from '@common/icons/material/Payments';
import {createSvgIconFromTree} from '@common/icons/create-svg-icon';
import {AccountCircleIcon} from '@common/icons/material/AccountCircle';
import {DarkModeIcon} from '@common/icons/material/DarkMode';
import {LightModeIcon} from '@common/icons/material/LightMode';
import {ExitToAppIcon} from '@common/icons/material/ExitToApp';
interface Props {
children: ReactElement;
items?: ReactElement<ListboxItemProps>[];
}
export function NavbarAuthMenu({children, items}: Props) {
const {auth} = useContext(SiteConfigContext);
const logout = useLogout();
const menu = useCustomMenu('auth-dropdown');
const {notifications, themes} = useSettings();
const {user, isSubscribed} = useAuth();
const navigate = useNavigate();
const {selectedTheme, selectTheme} = useThemeSelector();
if (!selectedTheme || !user) return null;
const hasUnreadNotif = !!user.unread_notifications_count;
const notifMenuItem = (
<MenuItem
className="md:hidden"
value="notifications"
startIcon={<NotificationsIcon />}
onSelected={() => {
navigate('/notifications');
}}
>
<Trans message="Notifications" />
{hasUnreadNotif ? ` (${user.unread_notifications_count})` : undefined}
</MenuItem>
);
const billingMenuItem = (
<MenuItem
value="billing"
startIcon={<PaymentsIcon />}
onSelected={() => {
navigate('/billing');
}}
>
<Trans message="Billing" />
</MenuItem>
);
return (
<MenuTrigger>
{children}
<Menu>
{menu &&
menu.items.map(item => {
const Icon = item.icon && createSvgIconFromTree(item.icon);
return (
<MenuItem
value={item.id}
key={item.id}
startIcon={Icon && <Icon />}
onSelected={() => {
if (item.type === 'link') {
window.open(item.action, '_blank');
} else {
navigate(item.action);
}
}}
>
<Trans message={item.label} />
</MenuItem>
);
})}
{auth.getUserProfileLink && (
<MenuItem
value="profile"
startIcon={<AccountCircleIcon />}
onSelected={() => {
navigate(auth.getUserProfileLink!(user));
}}
>
<Trans message="Profile page" />
</MenuItem>
)}
{items?.map(item => item)}
{notifications?.integrated ? notifMenuItem : undefined}
{isSubscribed && billingMenuItem}
{themes?.user_change && !selectedTheme.is_dark && (
<MenuItem
value="light"
startIcon={<DarkModeIcon />}
onSelected={() => {
selectTheme('dark');
}}
>
<Trans message="Dark mode" />
</MenuItem>
)}
{themes?.user_change && selectedTheme.is_dark && (
<MenuItem
value="dark"
startIcon={<LightModeIcon />}
onSelected={() => {
selectTheme('light');
}}
>
<Trans message="Light mode" />
</MenuItem>
)}
<MenuItem
value="logout"
startIcon={<ExitToAppIcon />}
onSelected={() => {
logout.mutate();
}}
>
<Trans message="Log out" />
</MenuItem>
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,58 @@
import {useAuth} from '@common/auth/use-auth';
import {useThemeSelector} from '@common/ui/themes/theme-selector-context';
import {Badge} from '@common/ui/badge/badge';
import {IconButton} from '@common/ui/buttons/icon-button';
import {PersonIcon} from '@common/icons/material/Person';
import {ButtonBase} from '@common/ui/buttons/button-base';
import {ArrowDropDownIcon} from '@common/icons/material/ArrowDropDown';
import {ReactElement} from 'react';
import {ListboxItemProps} from '@common/ui/forms/listbox/item';
import {NavbarAuthMenu} from '@common/ui/navigation/navbar/navbar-auth-menu';
export interface NavbarAuthUserProps {
items?: ReactElement<ListboxItemProps>[];
}
export function NavbarAuthUser({items = []}: NavbarAuthUserProps) {
const {user} = useAuth();
const {selectedTheme} = useThemeSelector();
if (!selectedTheme || !user) return null;
const hasUnreadNotif = !!user.unread_notifications_count;
const mobileButton = (
<IconButton
size="md"
className="md:hidden"
role="presentation"
aria-label="toggle authentication menu"
badge={
hasUnreadNotif ? (
<Badge>{user.unread_notifications_count}</Badge>
) : undefined
}
>
<PersonIcon />
</IconButton>
);
const desktopButton = (
<ButtonBase className="flex items-center max-md:hidden" role="presentation">
<img
className="mr-12 h-32 w-32 flex-shrink-0 rounded object-cover"
src={user.avatar}
alt=""
/>
<span className="mr-2 block max-w-124 overflow-x-hidden overflow-ellipsis text-sm">
{user.display_name}
</span>
<ArrowDropDownIcon className="block icon-sm" />
</ButtonBase>
);
return (
<NavbarAuthMenu items={items}>
<span role="button">
{mobileButton}
{desktopButton}
</span>
</NavbarAuthMenu>
);
}

View File

@@ -0,0 +1,198 @@
import {ReactElement, ReactNode} from 'react';
import clsx from 'clsx';
import {useAuth} from '@common/auth/use-auth';
import {NotificationDialogTrigger} from '@common/notifications/dialog/notification-dialog-trigger';
import {Menu, MenuTrigger} from '@common/ui/navigation/menu/menu-trigger';
import {useCustomMenu} from '@common/menus/use-custom-menu';
import {createSvgIconFromTree} from '@common/icons/create-svg-icon';
import {Trans} from '@common/i18n/trans';
import {IconButton} from '@common/ui/buttons/icon-button';
import {Item} from '@common/ui/forms/listbox/item';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {useIsDarkMode} from '@common/ui/themes/use-is-dark-mode';
import {CustomMenu} from '@common/menus/custom-menu';
import {useSettings} from '@common/core/settings/use-settings';
import {ButtonColor} from '@common/ui/buttons/get-shared-button-style';
import {MenuIcon} from '@common/icons/material/Menu';
import {MenuItemConfig} from '@common/core/settings/settings';
import {
NavbarAuthUser,
NavbarAuthUserProps,
} from '@common/ui/navigation/navbar/navbar-auth-user';
import {NavbarAuthButtons} from '@common/ui/navigation/navbar/navbar-auth-buttons';
import {useDarkThemeVariables} from '@common/ui/themes/use-dark-theme-variables';
import {Logo} from '@common/ui/navigation/navbar/logo';
import {useLightThemeVariables} from '@common/ui/themes/use-light-theme-variables';
import {isAbsoluteUrl} from '@common/utils/urls/is-absolute-url';
type NavbarColor = 'primary' | 'bg' | 'bg-alt' | 'transparent' | string;
export interface NavbarProps {
hideLogo?: boolean | null;
toggleButton?: ReactElement;
children?: ReactNode;
className?: string;
color?: NavbarColor;
bgOpacity?: number | string;
darkModeColor?: NavbarColor;
logoColor?: 'dark' | 'light';
textColor?: string;
primaryButtonColor?: ButtonColor;
border?: string;
size?: 'xs' | 'sm' | 'md';
rightChildren?: ReactNode;
menuPosition?: string;
authMenuItems?: NavbarAuthUserProps['items'];
alwaysDarkMode?: boolean;
wrapInContainer?: boolean;
}
export function Navbar(props: NavbarProps) {
let {
hideLogo,
toggleButton,
children,
className,
border,
size = 'md',
color,
textColor,
darkModeColor,
rightChildren,
menuPosition,
logoColor,
primaryButtonColor,
authMenuItems,
alwaysDarkMode = false,
wrapInContainer = false,
} = props;
const isDarkMode = useIsDarkMode() || alwaysDarkMode;
const {notifications} = useSettings();
const {isLoggedIn} = useAuth();
const darkThemeVars = useDarkThemeVariables();
const lightThemeVars = useLightThemeVariables();
const showNotifButton = isLoggedIn && notifications?.integrated;
color = color ?? lightThemeVars?.['--be-navbar-color'] ?? 'primary';
darkModeColor =
darkModeColor ?? darkModeColor?.['--be-navbar-color'] ?? 'bg-alt';
if (isDarkMode) {
color = darkModeColor;
}
return (
<div
style={alwaysDarkMode ? darkThemeVars : undefined}
className={clsx(
getColorStyle(color, textColor),
size === 'md' && 'h-64 py-8',
size === 'sm' && 'h-54 py-4',
size === 'xs' && 'h-48 py-4',
border,
className,
)}
>
<div
className={clsx(
'flex h-full items-center justify-end gap-10 pl-14 pr-8 md:pl-20 md:pr-20',
wrapInContainer && 'container mx-auto',
)}
>
{!hideLogo && (
<Logo isDarkMode={isDarkMode} color={color} logoColor={logoColor} />
)}
{toggleButton}
{children}
<MobileMenu position={menuPosition} />
<DesktopMenu position={menuPosition} />
<div className="ml-auto flex items-center gap-4 md:gap-14">
{rightChildren}
{showNotifButton && <NotificationDialogTrigger />}
{isLoggedIn ? (
<NavbarAuthUser items={authMenuItems} />
) : (
<NavbarAuthButtons
navbarColor={color}
primaryButtonColor={primaryButtonColor}
/>
)}
</div>
</div>
</div>
);
}
interface DesktopMenuProps {
position: NavbarProps['menuPosition'];
}
function DesktopMenu({position}: DesktopMenuProps) {
return (
<CustomMenu
className="mx-14 text-sm max-md:hidden"
itemClassName={isActive =>
clsx(
'opacity-90 hover:underline hover:opacity-100',
isActive && 'opacity-100',
)
}
menu={position}
/>
);
}
interface MobileMenuProps {
position: NavbarProps['menuPosition'];
}
function MobileMenu({position}: MobileMenuProps) {
const navigate = useNavigate();
const menu = useCustomMenu(position);
if (!menu?.items.length) {
return null;
}
const handleItemClick = (item: MenuItemConfig) => {
if (isAbsoluteUrl(item.action)) {
window.open(item.action, item.target)?.focus();
} else {
navigate(item.action);
}
};
return (
<MenuTrigger>
<IconButton className="md:hidden" aria-label="Toggle menu">
<MenuIcon />
</IconButton>
<Menu>
{menu.items.map(item => {
const Icon = item.icon && createSvgIconFromTree(item.icon);
return (
<Item
value={item.action}
onSelected={() => handleItemClick(item)}
key={item.id}
startIcon={Icon && <Icon />}
>
<Trans message={item.label} />
</Item>
);
})}
</Menu>
</MenuTrigger>
);
}
function getColorStyle(color: string, textColor?: string): string {
switch (color) {
case 'primary':
return `bg-primary ${textColor || 'text-on-primary'} border-b-primary`;
case 'bg':
return `bg ${textColor || 'text-main'} border-b`;
case 'bg-alt':
return `bg-alt ${textColor || 'text-main'} border-b`;
case 'transparent':
return `bg-transparent ${textColor || 'text-white'}`;
default:
return `${color} ${textColor}`;
}
}

View File

@@ -0,0 +1,186 @@
import {
hasNextPage,
hasPreviousPage,
LengthAwarePaginationResponse,
PaginationResponse,
SimplePaginationResponse,
} from '@common/http/backend-response/pagination-response';
import {Button} from '@common/ui/buttons/button';
import memoize from 'nano-memoize';
import {Link} from 'react-router-dom';
import clsx from 'clsx';
import {Trans} from '@common/i18n/trans';
import {KeyboardArrowRightIcon} from '@common/icons/material/KeyboardArrowRight';
import {KeyboardArrowLeftIcon} from '@common/icons/material/KeyboardArrowLeft';
import {scrollToTop} from '@common/ui/navigation/use-scroll-to-top';
import {useRef} from 'react';
import {FirstPageIcon} from '@common/icons/material/FirstPage';
export type PaginationControlsType = 'simple' | 'lengthAware';
interface Props {
pagination: PaginationResponse<unknown> | undefined;
className?: string;
type?: PaginationControlsType;
scrollToTop?: boolean;
}
export function PaginationControls({
pagination,
className,
type,
scrollToTop,
}: Props) {
if (
!pagination?.data?.length ||
(!hasNextPage(pagination) && !hasPreviousPage(pagination))
) {
return null;
}
const isLengthAware =
(!type || type === 'lengthAware') &&
'total' in pagination &&
pagination.total != null;
if (isLengthAware) {
return (
<LengthAwarePagination
data={pagination as LengthAwarePaginationResponse}
className={className}
scrollToTop={scrollToTop}
/>
);
}
return (
<SimplePagination
data={pagination as SimplePaginationResponse}
className={className}
scrollToTop={scrollToTop}
/>
);
}
interface LengthAwarePaginationProps {
data: LengthAwarePaginationResponse;
className?: string;
scrollToTop?: boolean;
}
function LengthAwarePagination({
data,
className,
scrollToTop: shouldScrollToTop,
}: LengthAwarePaginationProps) {
const ref = useRef<HTMLElement>(null);
const currentPage = data.current_page;
const total = data.total;
const perPage = data.per_page;
const range = generatePaginationRangeWithDots(currentPage, total, perPage);
return (
<nav
ref={ref}
className={clsx('flex flex-wrap items-center justify-center', className)}
>
<ul className="flex items-center gap-4">
{range.map((item, index) => {
const isCurrentPage = item === currentPage;
return (
<li key={item === '...' ? `...-${index}` : item}>
<Button
elementType={isCurrentPage ? undefined : Link}
to={!isCurrentPage ? `?page=${item}` : undefined}
variant={isCurrentPage ? 'outline' : undefined}
disabled={isCurrentPage || item === '...'}
onClick={shouldScrollToTop ? () => scrollToTop(ref) : undefined}
>
{item}
</Button>
</li>
);
})}
</ul>
</nav>
);
}
interface SimplePaginationProps {
data: SimplePaginationResponse<unknown>;
className?: string;
scrollToTop?: boolean;
}
function SimplePagination({
data,
className,
scrollToTop: shouldScrollToTop,
}: SimplePaginationProps) {
const ref = useRef<HTMLDivElement>(null);
const currentPage = data.current_page;
const isLastPage = !hasNextPage(data);
return (
<div ref={ref} className={clsx('flex items-center gap-12', className)}>
{currentPage > 1 && (
<Button
variant="outline"
elementType={Link}
className="min-w-110"
to="?page=1"
startIcon={<FirstPageIcon />}
onClick={shouldScrollToTop ? () => scrollToTop(ref) : undefined}
size="xs"
>
<Trans message="First" />
</Button>
)}
<Button
variant="outline"
elementType={currentPage == 1 ? undefined : Link}
disabled={currentPage == 1}
className="mr-auto min-w-110"
to={currentPage == 1 ? undefined : `?page=${currentPage - 1}`}
startIcon={<KeyboardArrowLeftIcon />}
onClick={shouldScrollToTop ? () => scrollToTop(ref) : undefined}
size="xs"
>
<Trans message="Previous" />
</Button>
<Button
variant="outline"
elementType={isLastPage ? undefined : Link}
disabled={isLastPage}
className="min-w-110"
to={isLastPage ? undefined : `?page=${currentPage + 1}`}
endIcon={<KeyboardArrowRightIcon />}
onClick={shouldScrollToTop ? () => scrollToTop(ref) : undefined}
size="xs"
>
<Trans message="Next" />
</Button>
</div>
);
}
const generatePaginationRangeWithDots = memoize(
(currentPage: number, total: number, perPage: number) => {
const totalPages = Math.ceil(total / perPage);
const delta = 3;
const range = [];
for (
let i = Math.max(2, currentPage - delta);
i <= Math.min(totalPages - 1, currentPage + delta);
i++
) {
range.push(i);
}
if (currentPage - delta > 2) {
range.unshift('...');
}
if (currentPage + delta < totalPages - 1) {
range.push('...');
}
range.unshift(1);
range.push(totalPages);
return range;
},
);

View File

@@ -0,0 +1,26 @@
import {useLocation} from 'react-router-dom';
import {RefObject, useEffect} from 'react';
import {usePrevious} from '@common/utils/hooks/use-previous';
import {getScrollParent} from '@react-aria/utils';
export function useScrollToTop(ref?: RefObject<HTMLElement>) {
const {pathname} = useLocation();
const previousPathname = usePrevious(pathname);
useEffect(() => {
if (previousPathname !== pathname) {
scrollToTop(ref);
}
}, [pathname, previousPathname, ref]);
}
export function scrollToTop(ref?: RefObject<HTMLElement>) {
const scrollParent = ref?.current
? getScrollParent(ref.current)
: document.documentElement;
scrollParent.scrollTo({
top: 0,
left: 0,
});
}