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

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

View File

@ -7,6 +7,12 @@
"journal.pl.close": "Close",
"journal.pl.title": "Attendance Journal",
"journal.pl.breadcrumbs.home": "Home",
"journal.pl.breadcrumbs.course": "Course",
"journal.pl.breadcrumbs.lesson": "Lesson",
"journal.pl.breadcrumbs.user": "User",
"journal.pl.breadcrumbs.attendance": "Attendance",
"journal.pl.common.add": "Add",
"journal.pl.common.edit": "Edit",
"journal.pl.common.delete": "Delete",

View File

@ -7,6 +7,12 @@
"journal.pl.close": "Закрыть",
"journal.pl.title": "Журнал посещаемости",
"journal.pl.breadcrumbs.home": "Главная",
"journal.pl.breadcrumbs.course": "Курс",
"journal.pl.breadcrumbs.lesson": "Лекция",
"journal.pl.breadcrumbs.user": "Пользователь",
"journal.pl.breadcrumbs.attendance": "Посещаемость",
"journal.pl.common.students": "студентов",
"journal.pl.common.teachers": "преподавателей",
"journal.pl.common.noData": "Нет данных",

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

View File

@ -12,7 +12,7 @@ import {
UserPage,
AttendancePage,
} from './pages'
import { ErrorBoundary, AppHeader } from './components'
import { ErrorBoundary, AppHeader, BreadcrumbsProvider, useBreadcrumbs } from './components'
import { keycloak } from './__data__/kc'
const MENU_SCRIPT_URL = 'https://admin.bro-js.ru/remote-assets/lib/serviceMenu/serviceMenu.js'
@ -62,6 +62,12 @@ const Wrapper = ({ children }: { children: React.ReactElement }) => (
</Suspense>
)
// Компонент, который соединяет хлебные крошки с AppHeader
const HeaderWithBreadcrumbs = ({ serviceMenuContainerRef }: { serviceMenuContainerRef: React.RefObject<HTMLDivElement> }) => {
const { breadcrumbs } = useBreadcrumbs();
return <AppHeader serviceMenuContainerRef={serviceMenuContainerRef} breadcrumbs={breadcrumbs} />;
};
interface DashboardProps {
store: any; // Используем any, поскольку точный тип store не указан
}
@ -111,49 +117,51 @@ export const Dashboard = ({ store }: DashboardProps) => {
return (
<Provider store={store}>
<AppHeader serviceMenuContainerRef={serviceMenuContainerRef} />
<Routes>
<Route
path={getNavigationValue('journal.main')}
element={
<Wrapper>
<CourseListPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}/lessons-list/:courseId`}
element={
<Wrapper>
<LessonListPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}/u/:lessonId/:accessId`}
element={
<Wrapper>
<UserPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}/lesson/:courseId/:lessonId`}
element={
<Wrapper>
<LessonDetailsPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`}
element={
<Wrapper>
<AttendancePage />
</Wrapper>
}
/>
</Routes>
<BreadcrumbsProvider>
<HeaderWithBreadcrumbs serviceMenuContainerRef={serviceMenuContainerRef} />
<Routes>
<Route
path={getNavigationValue('journal.main')}
element={
<Wrapper>
<CourseListPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}/lessons-list/:courseId`}
element={
<Wrapper>
<LessonListPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}/u/:lessonId/:accessId`}
element={
<Wrapper>
<UserPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}/lesson/:courseId/:lessonId`}
element={
<Wrapper>
<LessonDetailsPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`}
element={
<Wrapper>
<AttendancePage />
</Wrapper>
}
/>
</Routes>
</BreadcrumbsProvider>
</Provider>
)
}

View File

@ -12,6 +12,7 @@ import {
import { useTranslation } from 'react-i18next'
import { PageLoader } from '../../components/page-loader/page-loader'
import { useSetBreadcrumbs } from '../../components'
import { useAttendanceData, useAttendanceStats } from './hooks'
import { AttendanceTable, StatsCard } from './components'
@ -21,6 +22,22 @@ export const Attendance = () => {
const { t } = useTranslation()
const data = useAttendanceData(courseId)
const stats = useAttendanceStats(data)
// Устанавливаем хлебные крошки
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: data.courseInfo?.name || t('journal.pl.breadcrumbs.course'),
path: `/lessons-list/${courseId}`
},
{
title: t('journal.pl.breadcrumbs.attendance'),
isCurrentPage: true
}
])
if (data.isLoading) {
return <PageLoader />

View File

@ -15,6 +15,7 @@ import { useAppSelector } from '../../__data__/store'
import { api } from '../../__data__/api/api'
import { isTeacher } from '../../utils/user'
import { PageLoader } from '../../components/page-loader/page-loader'
import { useSetBreadcrumbs } from '../../components'
import { useGroupedCourses } from './hooks'
import { CreateCourseForm, YearGroup, CoursesOverview } from './components'
import { Lesson } from '../../__data__/model'
@ -29,6 +30,15 @@ export const CoursesList = () => {
const { t } = useTranslation()
const { colorMode } = useColorMode()
// Устанавливаем хлебные крошки для главной страницы
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/',
isCurrentPage: true
}
])
// Получаем значения фичей
const features = getFeatures('journal')
const coursesStatistics = features?.['courses.statistics']

View File

@ -6,9 +6,6 @@ import { getConfigValue, getNavigationValue } from '@brojs/cli'
import { motion, AnimatePresence } from 'framer-motion'
import {
Box,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Container,
VStack,
Heading,
@ -21,11 +18,11 @@ import { api } from '../__data__/api/api'
import { User } from '../__data__/model'
import { UserCard } from '../components/user-card'
import { formatDate } from '../utils/dayjs-config'
import { useSetBreadcrumbs } from '../components'
import {
QRCanvas,
StudentList,
BreadcrumbsWrapper,
} from './style'
import { useAppSelector } from '../__data__/store'
import { isTeacher } from '../utils/user'
@ -46,6 +43,26 @@ const LessonDetail = () => {
const { t } = useTranslation()
const { colorMode } = useColorMode()
// Получаем данные о курсе и уроке
const { data: courseData } = api.useGetCourseByIdQuery(courseId)
const { data: lessonData } = api.useLessonByIdQuery(lessonId)
// Устанавливаем хлебные крошки
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: courseData?.name || t('journal.pl.breadcrumbs.course'),
path: `${getNavigationValue('journal.main')}/lessons-list/${courseId}`
},
{
title: lessonData?.body?.name || t('journal.pl.breadcrumbs.lesson'),
isCurrentPage: true
}
])
// Создаем ref для отслеживания ранее присутствовавших студентов
const prevPresentStudentsRef = useRef(new Set<string>())
@ -201,28 +218,6 @@ const LessonDetail = () => {
return (
<>
<BreadcrumbsWrapper>
<Breadcrumb>
<BreadcrumbItem>
<BreadcrumbLink as={Link} to={getNavigationValue('journal.main')}>
{t('journal.pl.common.journal')}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink
as={Link}
to={`${getNavigationValue('journal.main')}/lessons-list/${courseId}`}
>
{t('journal.pl.common.course')}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink href="#">{t('journal.pl.common.lesson')}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
</BreadcrumbsWrapper>
<Container maxW="2280px">
<VStack align="left">
<Heading as="h3" mt="4" mb="3">

View File

@ -3,9 +3,6 @@ import dayjs, { formatDate } from '../../utils/dayjs-config'
import { generatePath, Link, useParams } from 'react-router-dom'
import { getNavigationValue, getFeatures } from '@brojs/cli'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Container,
Box,
Button,
@ -40,13 +37,12 @@ import { useAppSelector } from '../../__data__/store'
import { api } from '../../__data__/api/api'
import { isTeacher } from '../../utils/user'
import { Lesson } from '../../__data__/model'
import { XlSpinner } from '../../components/xl-spinner'
import { XlSpinner, useSetBreadcrumbs } from '../../components'
import { qrCode } from '../../assets'
import { LessonForm } from './components/lessons-form'
import { Bar } from './components/bar'
import { LessonItems } from './components/lesson-items'
import { BreadcrumbsWrapper } from './style'
import { CourseStatistics } from './components/statistics'
const features = getFeatures('journal')
@ -59,6 +55,7 @@ const LessonList = () => {
const { courseId } = useParams()
const user = useAppSelector((s) => s.user)
const { data, isLoading, error, isSuccess } = api.useLessonListQuery(courseId)
const { data: courseData } = api.useGetCourseByIdQuery(courseId)
const [generateLessonsMutation, {
data: generateLessons,
isLoading: isLoadingGenerateLessons,
@ -79,6 +76,19 @@ const LessonList = () => {
const [editLesson, setEditLesson] = useState<Lesson>(null)
const [suggestedLessonToCreate, setSuggestedLessonToCreate] = useState(null)
const { t } = useTranslation()
// Устанавливаем хлебные крошки для страницы списка уроков
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: courseData?.name || t('journal.pl.breadcrumbs.course'),
isCurrentPage: true
}
])
const sorted = useMemo(
() => [...(data?.body || [])]?.sort((a, b) => (a.date > b.date ? 1 : -1)),
[data, data?.body],
@ -330,19 +340,6 @@ const LessonList = () => {
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
<BreadcrumbsWrapper>
<Breadcrumb>
<BreadcrumbItem>
<BreadcrumbLink as={Link} to={getNavigationValue('journal.main')}>
{t('journal.pl.common.journal')}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink href="#">{t('journal.pl.common.course')}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
</BreadcrumbsWrapper>
<Container maxW="container.xl" position="relative">
{isTeacher(user) && (
<Box mt="15" mb="15">

View File

@ -20,6 +20,7 @@ import {
} from '@chakra-ui/react'
import { UserCard } from '../components/user-card'
import { StudentListView } from './style'
import { useSetBreadcrumbs } from '../components'
const UserPage = () => {
const { lessonId, accessId } = useParams()
@ -33,6 +34,18 @@ const UserPage = () => {
skipPollingIfUnfocused: true,
})
// Устанавливаем хлебные крошки
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: t('journal.pl.breadcrumbs.user'),
isCurrentPage: true
}
])
// Эффект для поэтапного появления карточек студентов
useEffect(() => {
if (ls.data?.body?.students?.length) {