Добавлены хлебные крошки для навигации в компонентах и страницах, включая CourseList, LessonList, UserPage и Attendance. Обновлены локализации для новых элементов навигации. Реализован контекст для управления состоянием хлебных крошек через BreadcrumbsProvider и useBreadcrumbs. Обновлен компонент AppHeader для отображения хлебных крошек в зависимости от текущей страницы.

This commit is contained in:
2025-03-23 23:11:27 +03:00
parent 4e27e3d1c6
commit c92be3d7dd
12 changed files with 432 additions and 128 deletions

View File

@@ -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>
);
};

View 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)]);
};

View File

@@ -0,0 +1 @@
export * from './breadcrumbs-context';

View File

@@ -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';