Добавлены хлебные крошки для навигации в компонентах и страницах, включая CourseList, LessonList, UserPage и Attendance. Обновлены локализации для новых элементов навигации. Реализован контекст для управления состоянием хлебных крошек через BreadcrumbsProvider и useBreadcrumbs. Обновлен компонент AppHeader для отображения хлебных крошек в зависимости от текущей страницы.
This commit is contained in:
@@ -1,22 +1,74 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
useColorMode,
|
||||
Button,
|
||||
HStack
|
||||
HStack,
|
||||
VStack,
|
||||
Container,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Heading,
|
||||
useBreakpointValue,
|
||||
Text,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
} from '@chakra-ui/react';
|
||||
import { MoonIcon, SunIcon } from '@chakra-ui/icons';
|
||||
import { MoonIcon, SunIcon, ChevronRightIcon, InfoIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { getNavigationValue } from '@brojs/cli';
|
||||
|
||||
interface AppHeaderProps {
|
||||
serviceMenuContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
breadcrumbs?: Array<{
|
||||
title: string;
|
||||
path?: string;
|
||||
isCurrentPage?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const AppHeader = ({ serviceMenuContainerRef }: AppHeaderProps) => {
|
||||
export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderProps) => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const { t, i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// Получаем путь к главной странице
|
||||
const mainPagePath = getNavigationValue('journal.main');
|
||||
|
||||
// Функция для формирования правильного пути с учетом mainPagePath
|
||||
const getFullPath = (path?: string): string => {
|
||||
if (!path) return '#';
|
||||
if (path === '/') return mainPagePath;
|
||||
|
||||
// Если путь уже начинается с mainPagePath, оставляем как есть
|
||||
if (path.startsWith(mainPagePath)) return path;
|
||||
|
||||
// Если путь начинается со слеша, добавляем mainPagePath
|
||||
if (path.startsWith('/')) return `${mainPagePath}${path}`;
|
||||
|
||||
// Иначе просто объединяем пути
|
||||
return `${mainPagePath}/${path}`;
|
||||
};
|
||||
|
||||
// Определяем размеры для разных устройств
|
||||
const fontSize = useBreakpointValue({ base: 'xs', sm: 'xs', md: 'sm' });
|
||||
|
||||
// Проверяем, на каком устройстве находимся
|
||||
const [isLargerThan768] = useMediaQuery("(min-width: 768px)");
|
||||
const [isLargerThan480] = useMediaQuery("(min-width: 480px)");
|
||||
|
||||
// Вертикальное отображение на мобильных устройствах
|
||||
const isMobile = !isLargerThan480;
|
||||
|
||||
// Горизонтальный сепаратор для десктопов
|
||||
const horizontalSeparator = useBreakpointValue({
|
||||
sm: <ChevronRightIcon color="gray.400" fontSize="xs" />,
|
||||
md: <ChevronRightIcon color="gray.400" />
|
||||
});
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language === 'ru' ? 'en' : 'ru';
|
||||
@@ -24,47 +76,200 @@ export const AppHeader = ({ serviceMenuContainerRef }: AppHeaderProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
<Box
|
||||
as="header"
|
||||
width="100%"
|
||||
py={4}
|
||||
px={8}
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
py={{ base: 2, md: 3 }}
|
||||
bg={colorMode === 'light' ? 'white' : 'gray.800'}
|
||||
boxShadow="sm"
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
bg={colorMode === 'light' ? 'white' : 'gray.800'}
|
||||
boxShadow="sm"
|
||||
>
|
||||
{serviceMenuContainerRef && <div id="dots" ref={serviceMenuContainerRef}></div>}
|
||||
<Box>
|
||||
|
||||
</Box>
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
onClick={toggleLanguage}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.language === 'ru'
|
||||
? t('journal.pl.lang.switchToEn')
|
||||
: t('journal.pl.lang.switchToRu')
|
||||
}
|
||||
>
|
||||
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
aria-label={colorMode === 'light'
|
||||
? t('journal.pl.theme.switchDark')
|
||||
: t('journal.pl.theme.switchLight')
|
||||
}
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
{/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */}
|
||||
{serviceMenuContainerRef && (
|
||||
<Box
|
||||
id="dots"
|
||||
ref={serviceMenuContainerRef}
|
||||
position="absolute"
|
||||
top="3"
|
||||
left="0"
|
||||
height="0"
|
||||
width="0"
|
||||
overflow="visible"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Container maxW="container.xl" px={{ base: 2, sm: 4 }}>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{/* Мобильная версия: верхняя строка с кнопками */}
|
||||
<Flex justifyContent="space-between" alignItems="center" h={{ base: "40px" }}>
|
||||
<Box>
|
||||
{/* Пустой контейнер для поддержания расположения */}
|
||||
</Box>
|
||||
|
||||
<HStack spacing={{ base: 1 }} flexShrink={0}>
|
||||
<Button
|
||||
onClick={toggleLanguage}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.language === 'ru'
|
||||
? t('journal.pl.lang.switchToEn')
|
||||
: t('journal.pl.lang.switchToRu')
|
||||
}
|
||||
fontSize={fontSize}
|
||||
px={{ base: 1 }}
|
||||
minW={{ base: "30px" }}
|
||||
h={{ base: "30px" }}
|
||||
>
|
||||
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
aria-label={colorMode === 'light'
|
||||
? t('journal.pl.theme.switchDark')
|
||||
: t('journal.pl.theme.switchLight')
|
||||
}
|
||||
icon={colorMode === 'light' ? <MoonIcon boxSize={{ base: "14px" }} /> : <SunIcon boxSize={{ base: "14px" }} />}
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
size={{ base: "sm" }}
|
||||
minW={{ base: "30px" }}
|
||||
h={{ base: "30px" }}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Вертикальные хлебные крошки */}
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<VStack
|
||||
align="flex-start"
|
||||
spacing={0}
|
||||
mt={1}
|
||||
>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<Flex
|
||||
key={index}
|
||||
align="center"
|
||||
w="100%"
|
||||
pl={index > 0 ? 3 : 1}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
_hover={!crumb.isCurrentPage && crumb.path ? {
|
||||
bg: colorMode === 'light' ? 'gray.50' : 'gray.700',
|
||||
} : {}}
|
||||
>
|
||||
{index > 0 && (
|
||||
<ChevronDownIcon
|
||||
color="gray.400"
|
||||
fontSize="10px"
|
||||
mr={2}
|
||||
transform="translateY(-2px)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{crumb.path && !crumb.isCurrentPage ? (
|
||||
<Link to={getFullPath(crumb.path)}>
|
||||
<Text
|
||||
fontWeight="medium"
|
||||
color={undefined}
|
||||
fontSize={fontSize}
|
||||
noOfLines={1}
|
||||
title={crumb.title}
|
||||
>
|
||||
{crumb.title}
|
||||
</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<Text
|
||||
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
|
||||
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
|
||||
fontSize={fontSize}
|
||||
noOfLines={1}
|
||||
title={crumb.title}
|
||||
>
|
||||
{crumb.title}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Десктопная версия: всё в одну строку */
|
||||
<Flex justifyContent="space-between" alignItems="center" h="40px">
|
||||
<Flex align="center" overflow="hidden" flex={1} minW={0}>
|
||||
{/* Контейнер для разметки */}
|
||||
<Box w="24px" mr={{ sm: 3, md: 4 }} flexShrink={0} />
|
||||
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<Breadcrumb
|
||||
fontWeight="medium"
|
||||
fontSize={fontSize}
|
||||
separator={horizontalSeparator}
|
||||
spacing={{ sm: "1" }}
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
minW={0}
|
||||
>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<BreadcrumbItem key={index} isCurrentPage={crumb.isCurrentPage}>
|
||||
<BreadcrumbLink
|
||||
as={crumb.path ? Link : undefined}
|
||||
to={getFullPath(crumb.path)}
|
||||
href={!crumb.path ? "#" : undefined}
|
||||
maxWidth={{ sm: "120px", md: "200px", lg: "300px" }}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
display="inline-block"
|
||||
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
|
||||
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
|
||||
title={crumb.title}
|
||||
>
|
||||
{crumb.title}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<HStack spacing={{ sm: 2, md: 4 }} flexShrink={0} ml={{ sm: 2, md: 3 }}>
|
||||
<Button
|
||||
onClick={toggleLanguage}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.language === 'ru'
|
||||
? t('journal.pl.lang.switchToEn')
|
||||
: t('journal.pl.lang.switchToRu')
|
||||
}
|
||||
fontSize={fontSize}
|
||||
px={{ sm: 2, md: 3 }}
|
||||
minW={{ sm: "40px" }}
|
||||
h={{ sm: "34px" }}
|
||||
>
|
||||
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
aria-label={colorMode === 'light'
|
||||
? t('journal.pl.theme.switchDark')
|
||||
: t('journal.pl.theme.switchLight')
|
||||
}
|
||||
icon={colorMode === 'light' ? <MoonIcon boxSize={{ sm: "14px", md: "16px" }} /> : <SunIcon boxSize={{ sm: "14px", md: "16px" }} />}
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
size={{ sm: "sm", md: "md" }}
|
||||
minW={{ sm: "34px" }}
|
||||
h={{ sm: "34px" }}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
45
src/components/breadcrumbs/breadcrumbs-context.tsx
Normal file
45
src/components/breadcrumbs/breadcrumbs-context.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
export type Breadcrumb = {
|
||||
title: string;
|
||||
path?: string;
|
||||
isCurrentPage?: boolean;
|
||||
};
|
||||
|
||||
type BreadcrumbsContextType = {
|
||||
breadcrumbs: Breadcrumb[];
|
||||
setBreadcrumbs: (breadcrumbs: Breadcrumb[]) => void;
|
||||
};
|
||||
|
||||
const BreadcrumbsContext = createContext<BreadcrumbsContextType | undefined>(undefined);
|
||||
|
||||
export const BreadcrumbsProvider: React.FC<{children: ReactNode}> = ({ children }) => {
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
|
||||
|
||||
return (
|
||||
<BreadcrumbsContext.Provider value={{ breadcrumbs, setBreadcrumbs }}>
|
||||
{children}
|
||||
</BreadcrumbsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBreadcrumbs = () => {
|
||||
const context = useContext(BreadcrumbsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useBreadcrumbs must be used within a BreadcrumbsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useSetBreadcrumbs = (newBreadcrumbs: Breadcrumb[]) => {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
React.useEffect(() => {
|
||||
setBreadcrumbs(newBreadcrumbs);
|
||||
|
||||
return () => {
|
||||
// При размонтировании компонента очищаем хлебные крошки
|
||||
setBreadcrumbs([]);
|
||||
};
|
||||
}, [setBreadcrumbs, JSON.stringify(newBreadcrumbs)]);
|
||||
};
|
||||
1
src/components/breadcrumbs/index.ts
Normal file
1
src/components/breadcrumbs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './breadcrumbs-context';
|
||||
@@ -1,4 +1,5 @@
|
||||
export { PageLoader } from './page-loader/page-loader';
|
||||
export { XlSpinner } from './xl-spinner/xl-spinner';
|
||||
export { ErrorBoundary } from './error-boundary';
|
||||
export { AppHeader } from './app-header';
|
||||
export { AppHeader } from './app-header';
|
||||
export { BreadcrumbsProvider, useBreadcrumbs, useSetBreadcrumbs } from './breadcrumbs';
|
||||
Reference in New Issue
Block a user