diff --git a/locales/en.json b/locales/en.json index b104121..ec25650 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/ru.json b/locales/ru.json index d4bc5b3..7bfb09d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Нет данных", diff --git a/src/components/app-header/app-header.tsx b/src/components/app-header/app-header.tsx index 98a4124..4e39a76 100644 --- a/src/components/app-header/app-header.tsx +++ b/src/components/app-header/app-header.tsx @@ -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; + 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: , + md: + }); const toggleLanguage = () => { const newLang = i18n.language === 'ru' ? 'en' : 'ru'; @@ -24,47 +76,200 @@ export const AppHeader = ({ serviceMenuContainerRef }: AppHeaderProps) => { }; return ( - - {serviceMenuContainerRef &&
} - - - - - - - : } - onClick={toggleColorMode} - variant="ghost" - size="md" + {/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */} + {serviceMenuContainerRef && ( + - -
+ )} + + + {isMobile ? ( + <> + {/* Мобильная версия: верхняя строка с кнопками */} + + + {/* Пустой контейнер для поддержания расположения */} + + + + + + : } + onClick={toggleColorMode} + variant="ghost" + size={{ base: "sm" }} + minW={{ base: "30px" }} + h={{ base: "30px" }} + /> + + + + {/* Вертикальные хлебные крошки */} + {breadcrumbs && breadcrumbs.length > 0 && ( + + {breadcrumbs.map((crumb, index) => ( + 0 ? 3 : 1} + py={1} + borderRadius="md" + _hover={!crumb.isCurrentPage && crumb.path ? { + bg: colorMode === 'light' ? 'gray.50' : 'gray.700', + } : {}} + > + {index > 0 && ( + + )} + + {crumb.path && !crumb.isCurrentPage ? ( + + + {crumb.title} + + + ) : ( + + {crumb.title} + + )} + + ))} + + )} + + ) : ( + /* Десктопная версия: всё в одну строку */ + + + {/* Контейнер для разметки */} + + + {breadcrumbs && breadcrumbs.length > 0 && ( + + {breadcrumbs.map((crumb, index) => ( + + + {crumb.title} + + + ))} + + )} + + + + + + : } + onClick={toggleColorMode} + variant="ghost" + size={{ sm: "sm", md: "md" }} + minW={{ sm: "34px" }} + h={{ sm: "34px" }} + /> + + + )} + + ); }; \ No newline at end of file diff --git a/src/components/breadcrumbs/breadcrumbs-context.tsx b/src/components/breadcrumbs/breadcrumbs-context.tsx new file mode 100644 index 0000000..1641a73 --- /dev/null +++ b/src/components/breadcrumbs/breadcrumbs-context.tsx @@ -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(undefined); + +export const BreadcrumbsProvider: React.FC<{children: ReactNode}> = ({ children }) => { + const [breadcrumbs, setBreadcrumbs] = useState([]); + + return ( + + {children} + + ); +}; + +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)]); +}; \ No newline at end of file diff --git a/src/components/breadcrumbs/index.ts b/src/components/breadcrumbs/index.ts new file mode 100644 index 0000000..959a0f4 --- /dev/null +++ b/src/components/breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from './breadcrumbs-context'; \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index f1f6840..f14925b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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'; \ No newline at end of file +export { AppHeader } from './app-header'; +export { BreadcrumbsProvider, useBreadcrumbs, useSetBreadcrumbs } from './breadcrumbs'; \ No newline at end of file diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 64d3958..f353abc 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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 }) => ( ) +// Компонент, который соединяет хлебные крошки с AppHeader +const HeaderWithBreadcrumbs = ({ serviceMenuContainerRef }: { serviceMenuContainerRef: React.RefObject }) => { + const { breadcrumbs } = useBreadcrumbs(); + return ; +}; + interface DashboardProps { store: any; // Используем any, поскольку точный тип store не указан } @@ -111,49 +117,51 @@ export const Dashboard = ({ store }: DashboardProps) => { return ( - - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - + + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + ) } diff --git a/src/pages/attendance/attendance.tsx b/src/pages/attendance/attendance.tsx index 6fa0e83..7e2f83b 100644 --- a/src/pages/attendance/attendance.tsx +++ b/src/pages/attendance/attendance.tsx @@ -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 diff --git a/src/pages/course-list/course-list.tsx b/src/pages/course-list/course-list.tsx index 9ebe06d..b6e454f 100644 --- a/src/pages/course-list/course-list.tsx +++ b/src/pages/course-list/course-list.tsx @@ -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'] diff --git a/src/pages/lesson-details.tsx b/src/pages/lesson-details.tsx index 3bfd693..703c896 100644 --- a/src/pages/lesson-details.tsx +++ b/src/pages/lesson-details.tsx @@ -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()) @@ -201,28 +218,6 @@ const LessonDetail = () => { return ( <> - - - - - {t('journal.pl.common.journal')} - - - - - - {t('journal.pl.common.course')} - - - - - {t('journal.pl.common.lesson')} - - - diff --git a/src/pages/lesson-list/lesson-list.tsx b/src/pages/lesson-list/lesson-list.tsx index 94e02ea..7bf9b47 100644 --- a/src/pages/lesson-list/lesson-list.tsx +++ b/src/pages/lesson-list/lesson-list.tsx @@ -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(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 = () => { - - - - - {t('journal.pl.common.journal')} - - - - - {t('journal.pl.common.course')} - - - {isTeacher(user) && ( diff --git a/src/pages/user-page.tsx b/src/pages/user-page.tsx index ccdfbf6..570aa13 100644 --- a/src/pages/user-page.tsx +++ b/src/pages/user-page.tsx @@ -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) {