103
common/resources/client/ui/navigation/menu/context-menu.tsx
Executable file
103
common/resources/client/ui/navigation/menu/context-menu.tsx
Executable 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,
|
||||
};
|
||||
}
|
||||
129
common/resources/client/ui/navigation/menu/menu-trigger.tsx
Executable file
129
common/resources/client/ui/navigation/menu/menu-trigger.tsx
Executable 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};
|
||||
54
common/resources/client/ui/navigation/navbar/logo.tsx
Executable file
54
common/resources/client/ui/navigation/navbar/logo.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
83
common/resources/client/ui/navigation/navbar/navbar-auth-buttons.tsx
Executable file
83
common/resources/client/ui/navigation/navbar/navbar-auth-buttons.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
137
common/resources/client/ui/navigation/navbar/navbar-auth-menu.tsx
Executable file
137
common/resources/client/ui/navigation/navbar/navbar-auth-menu.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
58
common/resources/client/ui/navigation/navbar/navbar-auth-user.tsx
Executable file
58
common/resources/client/ui/navigation/navbar/navbar-auth-user.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
198
common/resources/client/ui/navigation/navbar/navbar.tsx
Executable file
198
common/resources/client/ui/navigation/navbar/navbar.tsx
Executable 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}`;
|
||||
}
|
||||
}
|
||||
186
common/resources/client/ui/navigation/pagination-controls.tsx
Executable file
186
common/resources/client/ui/navigation/pagination-controls.tsx
Executable 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;
|
||||
},
|
||||
);
|
||||
26
common/resources/client/ui/navigation/use-scroll-to-top.ts
Executable file
26
common/resources/client/ui/navigation/use-scroll-to-top.ts
Executable 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user