Add optional learningMaterial field to ChallengeTask model and update API endpoints. Introduce LearningMaterialViewer component for displaying additional educational content in Markdown format. Enhance TaskWorkspace to conditionally render learning materials, improving user experience with task-related resources.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit

This commit is contained in:
2025-12-14 15:18:26 +03:00
parent 08b654bd4d
commit 0092e55b65
5 changed files with 563 additions and 58 deletions

View File

@@ -0,0 +1,271 @@
import React, { useState, useMemo } from 'react'
import {
Box,
Button,
HStack,
Text,
VStack,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface LearningMaterialViewerProps {
content: string
linesPerPage?: number
}
export const LearningMaterialViewer = ({
content,
linesPerPage = 30
}: LearningMaterialViewerProps) => {
const [currentPage, setCurrentPage] = useState(0)
// Разделяем контент на страницы по linesPerPage строк
const pages = useMemo(() => {
const lines = content.split('\n')
const pagesArray: string[] = []
for (let i = 0; i < lines.length; i += linesPerPage) {
const pageLines = lines.slice(i, i + linesPerPage)
pagesArray.push(pageLines.join('\n'))
}
return pagesArray
}, [content, linesPerPage])
const totalPages = pages.length
if (totalPages === 0) {
return null
}
const goToPrevious = () => {
setCurrentPage(prev => Math.max(0, prev - 1))
}
const goToNext = () => {
setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))
}
return (
<Box
borderWidth="1px"
borderRadius="md"
borderColor="blue.200"
p={4}
bg="blue.50"
shadow="sm"
>
<VStack align="stretch" gap={3}>
<HStack justify="space-between" align="center">
<Text fontSize="lg" fontWeight="bold" color="blue.800">
Дополнительные материалы
</Text>
{totalPages > 1 && (
<Text fontSize="sm" color="blue.600">
Страница {currentPage + 1} из {totalPages}
</Text>
)}
</HStack>
<Box
color="gray.700"
fontSize="sm"
lineHeight="1.7"
css={{
// Заголовки
'& h1': {
fontSize: '1.75em',
fontWeight: '700',
marginTop: '1.2em',
marginBottom: '0.6em',
color: '#2D3748',
borderBottom: '2px solid #E2E8F0',
paddingBottom: '0.3em'
},
'& h2': {
fontSize: '1.5em',
fontWeight: '600',
marginTop: '1em',
marginBottom: '0.5em',
color: '#2D3748'
},
'& h3': {
fontSize: '1.25em',
fontWeight: '600',
marginTop: '0.8em',
marginBottom: '0.4em',
color: '#2D3748'
},
'& h4': {
fontSize: '1.1em',
fontWeight: '600',
marginTop: '0.6em',
marginBottom: '0.3em',
color: '#4A5568'
},
// Параграфы
'& p': {
marginTop: '0.75em',
marginBottom: '0.75em',
lineHeight: '1.8'
},
// Списки
'& ul, & ol': {
marginLeft: '1.5em',
marginTop: '0.75em',
marginBottom: '0.75em',
paddingLeft: '0.5em'
},
'& li': {
marginTop: '0.4em',
marginBottom: '0.4em',
lineHeight: '1.7'
},
'& li > p': {
marginTop: '0.25em',
marginBottom: '0.25em'
},
// Инлайн-код
'& code': {
backgroundColor: '#EDF2F7',
color: '#C53030',
padding: '0.15em 0.4em',
borderRadius: '4px',
fontSize: '0.9em',
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
fontWeight: '500'
},
// Блоки кода
'& pre': {
backgroundColor: '#1A202C',
color: '#E2E8F0',
padding: '1em 1.2em',
borderRadius: '8px',
overflowX: 'auto',
marginTop: '1em',
marginBottom: '1em',
border: '1px solid #2D3748',
fontSize: '0.9em',
lineHeight: '1.6'
},
'& pre code': {
backgroundColor: 'transparent',
color: '#E2E8F0',
padding: '0',
fontFamily: 'Monaco, Consolas, "Courier New", monospace'
},
// Цитаты
'& blockquote': {
borderLeft: '4px solid #4299E1',
paddingLeft: '1em',
paddingTop: '0.5em',
paddingBottom: '0.5em',
marginLeft: '0',
marginTop: '1em',
marginBottom: '1em',
fontStyle: 'italic',
color: '#4A5568',
backgroundColor: '#EBF8FF',
borderRadius: '0 4px 4px 0'
},
'& blockquote p': {
marginTop: '0.25em',
marginBottom: '0.25em'
},
// Ссылки
'& a': {
color: '#3182CE',
textDecoration: 'underline',
fontWeight: '500',
transition: 'color 0.2s',
'&:hover': {
color: '#2C5282'
}
},
// Горизонтальная линия
'& hr': {
border: 'none',
borderTop: '2px solid #E2E8F0',
marginTop: '1.5em',
marginBottom: '1.5em'
},
// Таблицы
'& table': {
borderCollapse: 'collapse',
width: '100%',
marginTop: '1em',
marginBottom: '1em',
fontSize: '0.95em'
},
'& table thead': {
backgroundColor: '#F7FAFC'
},
'& table th': {
border: '1px solid #E2E8F0',
padding: '0.75em 1em',
textAlign: 'left',
fontWeight: '600',
color: '#2D3748'
},
'& table td': {
border: '1px solid #E2E8F0',
padding: '0.75em 1em',
textAlign: 'left'
},
'& table tr:nth-of-type(even)': {
backgroundColor: '#F7FAFC'
},
// Выделение (strong, em)
'& strong': {
fontWeight: '600',
color: '#2D3748'
},
'& em': {
fontStyle: 'italic'
},
// Изображения
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: '8px',
marginTop: '1em',
marginBottom: '1em'
}
}}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{pages[currentPage]}
</ReactMarkdown>
</Box>
{totalPages > 1 && (
<HStack justify="center" gap={2}>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={goToPrevious}
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={currentPage === 0}
leftIcon={<Text></Text>}
>
Предыдущая
</Button>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={goToNext}
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={currentPage === totalPages - 1}
rightIcon={<Text></Text>}
>
Следующая
</Button>
</HStack>
)}
</VStack>
</Box>
)
}