Compare commits
6 Commits
feature/ad
...
feature/mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dad6253877 | ||
|
|
174547791b | ||
|
|
92e0b474a4 | ||
|
|
c57957f15c | ||
|
|
53d7b90c72 | ||
|
|
5af3fbfdc3 |
14
README.md
14
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Only Task - Интерактивная временная шкала
|
# Only Task
|
||||||
|
|
||||||
Современное React-приложение с интерактивной круговой временной шкалой и каруселью исторических событий.
|
Тестовое задание для only.digital
|
||||||
|
|
||||||
## 🚀 Технологии
|
## 🚀 Технологии
|
||||||
|
|
||||||
@@ -218,14 +218,4 @@ Pre-push hook автоматически запускает:
|
|||||||
|
|
||||||
ISC
|
ISC
|
||||||
|
|
||||||
## 👨💻 Разработка
|
|
||||||
|
|
||||||
Для разработки рекомендуется:
|
|
||||||
1. Запустить `pnpm dev` для dev-сервера
|
|
||||||
2. Использовать `pnpm storybook` для разработки компонентов в изоляции
|
|
||||||
3. Писать тесты для новых компонентов
|
|
||||||
4. Следовать существующей структуре проекта
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Приятной разработки! 🚀**
|
**Приятной разработки! 🚀**
|
||||||
|
|||||||
@@ -94,12 +94,22 @@ Babel используется через `babel-loader` в webpack конфиг
|
|||||||
```typescript
|
```typescript
|
||||||
// config/build/loaders/buildBabelLoader.ts
|
// config/build/loaders/buildBabelLoader.ts
|
||||||
{
|
{
|
||||||
test: /\.(js|jsx|tsx)$/,
|
test: /\.(js|jsx|tsx|ts)$/,
|
||||||
exclude: /node_modules/,
|
exclude: [
|
||||||
|
/node_modules/,
|
||||||
|
/\.test\.(ts|tsx)$/, // Исключаем тестовые файлы
|
||||||
|
/\.spec\.(ts|tsx)$/, // Исключаем spec файлы
|
||||||
|
/\.stories\.(ts|tsx)$/ // Исключаем Storybook файлы
|
||||||
|
],
|
||||||
use: {
|
use: {
|
||||||
loader: 'babel-loader',
|
loader: 'babel-loader',
|
||||||
options: {
|
options: {
|
||||||
presets: ['@babel/preset-env'],
|
cacheDirectory: true, // Кеширование для ускорения пересборки
|
||||||
|
presets: [
|
||||||
|
'@babel/preset-env',
|
||||||
|
['@babel/preset-react', { runtime: 'automatic' }],
|
||||||
|
'@babel/preset-typescript' // Компиляция TypeScript через Babel
|
||||||
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
isDev && require.resolve('react-refresh/babel')
|
isDev && require.resolve('react-refresh/babel')
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
@@ -108,6 +118,9 @@ Babel используется через `babel-loader` в webpack конфиг
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Важно:** TypeScript компилируется через Babel, а не через `ts-loader`.
|
||||||
|
Проверка типов выполняется отдельно через `pnpm type-check` (tsc --noEmit).
|
||||||
|
|
||||||
## React Refresh (только в dev режиме)
|
## React Refresh (только в dev режиме)
|
||||||
|
|
||||||
В режиме разработки добавляется плагин `react-refresh/babel` для горячей перезагрузки React компонентов без потери состояния.
|
В режиме разработки добавляется плагин `react-refresh/babel` для горячей перезагрузки React компонентов без потери состояния.
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import { BuildOptions } from './types/config'
|
|||||||
* Текущий порядок:
|
* Текущий порядок:
|
||||||
* 1. fileLoader - обрабатывает изображения и шрифты
|
* 1. fileLoader - обрабатывает изображения и шрифты
|
||||||
* 2. svgrLoader - преобразует SVG в React компоненты
|
* 2. svgrLoader - преобразует SVG в React компоненты
|
||||||
* 3. babelLoader - транспилирует JS/JSX/TSX с React Refresh
|
* 3. babelLoader - транспилирует JS/JSX/TS/TSX с помощью Babel (включая TypeScript)
|
||||||
* 4. typescriptLoader - компилирует TypeScript
|
* 4. cssLoader - обрабатывает CSS/SCSS с модулями
|
||||||
* 5. cssLoader - обрабатывает CSS/SCSS с модулями
|
*
|
||||||
|
* Примечание: TypeScript компилируется через Babel (@babel/preset-typescript),
|
||||||
|
* а не через ts-loader. Проверка типов выполняется отдельно через `tsc --noEmit`.
|
||||||
*
|
*
|
||||||
* @param {BuildOptions} options - Опции сборки
|
* @param {BuildOptions} options - Опции сборки
|
||||||
* @param {boolean} options.isDev - Флаг режима разработки
|
* @param {boolean} options.isDev - Флаг режима разработки
|
||||||
|
|||||||
@@ -22,3 +22,15 @@ jest.mock('gsap', () => {
|
|||||||
Power2: gsapMock.Power2, // Экспортируем Power2 отдельно
|
Power2: gsapMock.Power2, // Экспортируем Power2 отдельно
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Глобальный мок для @gsap/react
|
||||||
|
jest.mock('@gsap/react', () => ({
|
||||||
|
useGSAP: (fn: () => void) => {
|
||||||
|
// Выполняем функцию немедленно в тестах
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const { useEffect } = require('react')
|
||||||
|
useEffect(() => {
|
||||||
|
fn()
|
||||||
|
}, [])
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@gsap/react": "^2.1.2",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@gsap/react':
|
||||||
|
specifier: ^2.1.2
|
||||||
|
version: 2.1.2(gsap@3.13.0)(react@19.2.0)
|
||||||
classnames:
|
classnames:
|
||||||
specifier: ^2.5.1
|
specifier: ^2.5.1
|
||||||
version: 2.5.1
|
version: 2.5.1
|
||||||
@@ -1111,6 +1114,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@gsap/react@2.1.2':
|
||||||
|
resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==}
|
||||||
|
peerDependencies:
|
||||||
|
gsap: ^3.12.5
|
||||||
|
react: '>=17'
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -6799,6 +6808,11 @@ snapshots:
|
|||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@gsap/react@2.1.2(gsap@3.13.0)(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
gsap: 3.13.0
|
||||||
|
react: 19.2.0
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
* Поддерживает клик по точкам для переключения периодов.
|
* Поддерживает клик по точкам для переключения периодов.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useGSAP } from '@gsap/react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { gsap } from 'gsap'
|
import { gsap } from 'gsap'
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
import { memo, useCallback, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import styles from './CircleTimeline.module.scss'
|
import styles from './CircleTimeline.module.scss'
|
||||||
import { calculateCoordinates } from '../../lib/utils/calculateCoordinates/calculateCoordinates'
|
import { calculateCoordinates } from '../../lib/utils/calculateCoordinates/calculateCoordinates'
|
||||||
@@ -71,52 +72,55 @@ export const CircleTimeline = memo(function CircleTimeline({
|
|||||||
const titlesRef = useRef<(HTMLSpanElement | null)[]>([])
|
const titlesRef = useRef<(HTMLSpanElement | null)[]>([])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Эффект для анимации поворота круга и контр-поворота точек
|
* Анимация поворота круга и контр-поворота точек
|
||||||
* Запускается при изменении rotation
|
* Использует useGSAP hook для автоматической очистки анимаций
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useGSAP(
|
||||||
// Анимация поворота контейнера круга
|
() => {
|
||||||
if (circleRef.current) {
|
// Анимация поворота контейнера круга
|
||||||
gsap.to(circleRef.current, {
|
if (circleRef.current) {
|
||||||
rotation,
|
gsap.to(circleRef.current, {
|
||||||
duration: ANIMATION_DURATION,
|
rotation,
|
||||||
ease: ANIMATION_EASE,
|
duration: ANIMATION_DURATION,
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Контр-поворот точек, чтобы текст оставался читаемым
|
|
||||||
pointsRef.current.forEach((point, index) => {
|
|
||||||
if (point) {
|
|
||||||
gsap.to(point, {
|
|
||||||
rotation: -rotation,
|
|
||||||
duration: 0,
|
|
||||||
ease: ANIMATION_EASE,
|
ease: ANIMATION_EASE,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Анимация заголовка
|
// Контр-поворот точек, чтобы текст оставался читаемым
|
||||||
const title = titlesRef.current[index]
|
pointsRef.current.forEach((point, index) => {
|
||||||
if (title) {
|
if (point) {
|
||||||
// Сбрасываем предыдущие анимации для этого элемента
|
gsap.to(point, {
|
||||||
gsap.killTweensOf(title)
|
rotation: -rotation,
|
||||||
|
duration: 0,
|
||||||
|
ease: ANIMATION_EASE,
|
||||||
|
})
|
||||||
|
|
||||||
if (index === activeIndex) {
|
// Анимация заголовка
|
||||||
gsap.to(title, {
|
const title = titlesRef.current[index]
|
||||||
opacity: 1,
|
if (title) {
|
||||||
visibility: 'visible',
|
// Останавливаем предыдущие анимации для предотвращения конфликтов
|
||||||
duration: 0.5,
|
gsap.killTweensOf(title)
|
||||||
delay: ANIMATION_DURATION, // Ждем окончания вращения
|
|
||||||
})
|
if (index === activeIndex) {
|
||||||
} else {
|
gsap.to(title, {
|
||||||
gsap.to(title, {
|
opacity: 1,
|
||||||
opacity: 0,
|
visibility: 'visible',
|
||||||
visibility: 'hidden',
|
duration: 0.5,
|
||||||
duration: 0.2,
|
delay: ANIMATION_DURATION, // Ждем окончания вращения
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
gsap.to(title, {
|
||||||
|
opacity: 0,
|
||||||
|
visibility: 'hidden',
|
||||||
|
duration: 0.2,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
},
|
||||||
}, [rotation, activeIndex])
|
{ dependencies: [rotation, activeIndex] }
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Мемоизированный расчет позиций точек на круге
|
* Мемоизированный расчет позиций точек на круге
|
||||||
|
|||||||
@@ -33,27 +33,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.swiper) {
|
:global(.swiper) {
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 768px) {
|
||||||
padding: 0 20px;
|
padding: 0 40px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.swiper-slide-next) {
|
:global(.swiper-slide-visible) {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width < 768px) {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.swiper-slide-prev) {
|
:global(.swiper-slide-fully-visible) {
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
|
|
||||||
@media (width <=768px) {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.swiper-slide-active) {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
* Отображает список исторических событий в виде слайдера
|
* Отображает список исторических событий в виде слайдера
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useGSAP } from '@gsap/react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { gsap } from 'gsap'
|
import { gsap } from 'gsap'
|
||||||
import { memo, useEffect, useRef, useState } from 'react'
|
import { memo, useRef, useState } from 'react'
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
|
|
||||||
import 'swiper/css'
|
import 'swiper/css'
|
||||||
@@ -45,7 +46,7 @@ export interface EventsCarouselProps {
|
|||||||
*
|
*
|
||||||
* Использует Swiper для создания слайдера с кастомной навигацией.
|
* Использует Swiper для создания слайдера с кастомной навигацией.
|
||||||
* Поддерживает адаптивное количество слайдов на разных размерах экрана.
|
* Поддерживает адаптивное количество слайдов на разных размерах экрана.
|
||||||
* Анимирует появление/исчезновение с помощью GSAP.
|
* Анимирует появление/исчезновение с помощью GSAP useGSAP hook.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
@@ -62,13 +63,11 @@ export const EventsCarousel = memo(
|
|||||||
const [isEnd, setIsEnd] = useState(false)
|
const [isEnd, setIsEnd] = useState(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Эффект для анимации появления/исчезновения карусели
|
* Анимация появления/исчезновения карусели
|
||||||
* Использует GSAP для плавной анимации opacity и y-позиции
|
* Использует useGSAP hook для автоматической очистки анимаций
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useGSAP(
|
||||||
if (!containerRef.current) return
|
() => {
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
if (visible) {
|
if (visible) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(
|
||||||
containerRef.current,
|
containerRef.current,
|
||||||
@@ -86,10 +85,9 @@ export const EventsCarousel = memo(
|
|||||||
duration: HIDE_DURATION,
|
duration: HIDE_DURATION,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, containerRef)
|
},
|
||||||
|
{ scope: containerRef, dependencies: [visible, events] }
|
||||||
return () => ctx.revert()
|
)
|
||||||
}, [visible, events])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработчик инициализации Swiper
|
* Обработчик инициализации Swiper
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const EVENT_CAROUSEL_CONFIG: SwiperOptions = {
|
|||||||
},
|
},
|
||||||
breakpoints: {
|
breakpoints: {
|
||||||
576: {
|
576: {
|
||||||
slidesPerView: 2,
|
slidesPerView: 2.5,
|
||||||
},
|
},
|
||||||
768: {
|
768: {
|
||||||
slidesPerView: 2,
|
slidesPerView: 2,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/* Wrapper для container queries - должен быть родителем контейнера */
|
||||||
|
.wrapper {
|
||||||
|
/* Включаем container queries для адаптивности виджета */
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: timeframe-slider;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@@ -23,11 +30,11 @@
|
|||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@media (width <=1024px) {
|
@container timeframe-slider (width <= 1024px) {
|
||||||
padding-top: 100px;
|
padding-top: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 576px) {
|
||||||
padding: 60px 20px 20px;
|
padding: 60px 20px 20px;
|
||||||
|
|
||||||
background-image: unset;
|
background-image: unset;
|
||||||
@@ -51,20 +58,25 @@
|
|||||||
border-image: var(--gradient-primary) 1;
|
border-image: var(--gradient-primary) 1;
|
||||||
|
|
||||||
|
|
||||||
@media (width <=1024px) {
|
@container timeframe-slider (width <= 1024px) {
|
||||||
top: 80px;
|
top: 80px;
|
||||||
|
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 768px) {
|
||||||
|
padding-left: 20px;
|
||||||
|
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 576px) {
|
||||||
position: relative;
|
position: relative;
|
||||||
inset: unset;
|
inset: unset;
|
||||||
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|
||||||
font-size: 20px;
|
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
@@ -85,7 +97,7 @@
|
|||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: 100% 1px;
|
background-size: 100% 1px;
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 576px) {
|
||||||
position: unset;
|
position: unset;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -111,19 +123,23 @@
|
|||||||
|
|
||||||
transform-origin: left;
|
transform-origin: left;
|
||||||
|
|
||||||
@media (width <=1024px) {
|
@container timeframe-slider (width <= 1024px) {
|
||||||
left: 100px;
|
left: 100px;
|
||||||
bottom: 40px;
|
bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 768px) {
|
||||||
|
left: 40px;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 576px) {
|
||||||
left: 20px;
|
left: 20px;
|
||||||
bottom: 13px;
|
bottom: 13px;
|
||||||
|
|
||||||
order: 2;
|
order: 2;
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -138,7 +154,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 768px) {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +163,7 @@
|
|||||||
width: 9px;
|
width: 9px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 576px) {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 11.5px;
|
height: 11.5px;
|
||||||
}
|
}
|
||||||
@@ -156,7 +172,7 @@
|
|||||||
.dots {
|
.dots {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 576px) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 32px;
|
bottom: 32px;
|
||||||
@@ -214,14 +230,19 @@
|
|||||||
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
@media (width <=1024px) {
|
@container timeframe-slider (width <= 1024px) {
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
|
|
||||||
font-size: 140px;
|
font-size: 140px;
|
||||||
line-height: 120px;
|
line-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 768px) {
|
||||||
|
font-size: 100px;
|
||||||
|
line-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 576px) {
|
||||||
position: static;
|
position: static;
|
||||||
|
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
@@ -246,7 +267,7 @@
|
|||||||
.periodLabel {
|
.periodLabel {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 576px) {
|
||||||
order: 1;
|
order: 1;
|
||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
@@ -266,7 +287,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 576px) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,7 +295,7 @@
|
|||||||
.carouselContainer {
|
.carouselContainer {
|
||||||
padding: 55px 80px 105px;
|
padding: 55px 80px 105px;
|
||||||
|
|
||||||
@media (width <=768px) {
|
@container timeframe-slider (width <= 768px) {
|
||||||
width: calc(100% + 40px);
|
width: calc(100% + 40px);
|
||||||
margin: 0 -20px;
|
margin: 0 -20px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий
|
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useGSAP } from '@gsap/react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { gsap } from 'gsap'
|
import { gsap } from 'gsap'
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -21,7 +22,7 @@ import { EventsCarousel } from '../EventsCarousel/EventsCarousel'
|
|||||||
*
|
*
|
||||||
* Отображает исторические периоды на круговой диаграмме с возможностью
|
* Отображает исторические периоды на круговой диаграмме с возможностью
|
||||||
* переключения между ними. Для каждого периода показывается карусель событий.
|
* переключения между ними. Для каждого периода показывается карусель событий.
|
||||||
* Центральные даты анимируются при смене периода с помощью GSAP.
|
* Центральные даты анимируются при смене периода с помощью GSAP useGSAP hook.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
@@ -66,13 +67,11 @@ export const TimeFrameSlider = memo(() => {
|
|||||||
}, [activePeriod, anglePerPoint])
|
}, [activePeriod, anglePerPoint])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Анимация центральных дат с использованием GSAP
|
* Анимация центральных дат с использованием GSAP useGSAP hook
|
||||||
* Плавно изменяет числа при смене периода
|
* Плавно изменяет числа при смене периода
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useGSAP(
|
||||||
if (!containerRef.current) return
|
() => {
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
if (startYearRef.current) {
|
if (startYearRef.current) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(
|
||||||
startYearRef.current,
|
startYearRef.current,
|
||||||
@@ -102,6 +101,7 @@ export const TimeFrameSlider = memo(() => {
|
|||||||
prevYearFromRef.current = currentPeriod.yearFrom
|
prevYearFromRef.current = currentPeriod.yearFrom
|
||||||
prevYearToRef.current = currentPeriod.yearTo
|
prevYearToRef.current = currentPeriod.yearTo
|
||||||
|
|
||||||
|
// Анимация появления лейбла периода
|
||||||
if (periodLabelRef.current) {
|
if (periodLabelRef.current) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(
|
||||||
periodLabelRef.current,
|
periodLabelRef.current,
|
||||||
@@ -109,10 +109,12 @@ export const TimeFrameSlider = memo(() => {
|
|||||||
{ opacity: 1, visibility: 'visible', duration: 1 }
|
{ opacity: 1, visibility: 'visible', duration: 1 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, containerRef)
|
},
|
||||||
|
{
|
||||||
return () => ctx.revert()
|
scope: containerRef,
|
||||||
}, [currentPeriod.yearFrom, currentPeriod.yearTo])
|
dependencies: [currentPeriod.yearFrom, currentPeriod.yearTo],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переключение на предыдущий период
|
* Переключение на предыдущий период
|
||||||
@@ -131,74 +133,76 @@ export const TimeFrameSlider = memo(() => {
|
|||||||
}, [totalPeriods])
|
}, [totalPeriods])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} ref={containerRef}>
|
<div className={styles.wrapper}>
|
||||||
<h1 className={styles.title}>Исторические даты</h1>
|
<div className={styles.container} ref={containerRef}>
|
||||||
|
<h1 className={styles.title}>Исторические даты</h1>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.centerDate}>
|
<div className={styles.centerDate}>
|
||||||
<span ref={startYearRef}>{currentPeriod.yearFrom}</span>
|
<span ref={startYearRef}>{currentPeriod.yearFrom}</span>
|
||||||
<span ref={endYearRef}>{currentPeriod.yearTo}</span>
|
<span ref={endYearRef}>{currentPeriod.yearTo}</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.periodLabel} ref={periodLabelRef}>
|
|
||||||
{currentPeriod.label}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.circleContainer}>
|
|
||||||
<CircleTimeline
|
|
||||||
periods={HISTORICAL_PERIODS}
|
|
||||||
activeIndex={activePeriod}
|
|
||||||
onPeriodChange={setActivePeriod}
|
|
||||||
rotation={rotation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.controls}>
|
|
||||||
<div className={styles.pagination}>
|
|
||||||
{String(activePeriod + 1).padStart(2, '0')}/
|
|
||||||
{String(totalPeriods).padStart(2, '0')}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.buttons}>
|
|
||||||
<Button
|
<div className={styles.periodLabel} ref={periodLabelRef}>
|
||||||
variant='round'
|
{currentPeriod.label}
|
||||||
size='medium'
|
</div>
|
||||||
colorScheme='primary'
|
|
||||||
onClick={handlePrev}
|
<div className={styles.circleContainer}>
|
||||||
aria-label='Предыдущий период'
|
<CircleTimeline
|
||||||
>
|
periods={HISTORICAL_PERIODS}
|
||||||
<ChevronSvg className={styles.chevronIcon} stroke='#42567A' />
|
activeIndex={activePeriod}
|
||||||
</Button>
|
onPeriodChange={setActivePeriod}
|
||||||
<Button
|
rotation={rotation}
|
||||||
variant='round'
|
/>
|
||||||
size='medium'
|
</div>
|
||||||
colorScheme='primary'
|
|
||||||
onClick={handleNext}
|
<div className={styles.controls}>
|
||||||
aria-label='Следующий период'
|
<div className={styles.pagination}>
|
||||||
>
|
{String(activePeriod + 1).padStart(2, '0')}/
|
||||||
<ChevronSvg
|
{String(totalPeriods).padStart(2, '0')}
|
||||||
className={classNames(styles.chevronIcon, styles.rotated)}
|
</div>
|
||||||
stroke='#42567A'
|
<div className={styles.buttons}>
|
||||||
/>
|
<Button
|
||||||
</Button>
|
variant='round'
|
||||||
|
size='medium'
|
||||||
|
colorScheme='primary'
|
||||||
|
onClick={handlePrev}
|
||||||
|
aria-label='Предыдущий период'
|
||||||
|
>
|
||||||
|
<ChevronSvg className={styles.chevronIcon} stroke='#42567A' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='round'
|
||||||
|
size='medium'
|
||||||
|
colorScheme='primary'
|
||||||
|
onClick={handleNext}
|
||||||
|
aria-label='Следующий период'
|
||||||
|
>
|
||||||
|
<ChevronSvg
|
||||||
|
className={classNames(styles.chevronIcon, styles.rotated)}
|
||||||
|
stroke='#42567A'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.carouselContainer}>
|
<div className={styles.carouselContainer}>
|
||||||
<EventsCarousel events={currentPeriod.events} visible />
|
<EventsCarousel events={currentPeriod.events} visible />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.dots}>
|
<div className={styles.dots}>
|
||||||
{HISTORICAL_PERIODS.map((_, index) => (
|
{HISTORICAL_PERIODS.map((_, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className={classNames(styles.dot, {
|
className={classNames(styles.dot, {
|
||||||
[styles.activeDot]: index === activePeriod,
|
[styles.activeDot]: index === activePeriod,
|
||||||
})}
|
})}
|
||||||
onClick={() => setActivePeriod(index)}
|
onClick={() => setActivePeriod(index)}
|
||||||
aria-label={`Перейти к периоду ${index + 1}`}
|
aria-label={`Перейти к периоду ${index + 1}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,10 +34,20 @@
|
|||||||
- **paths**: Настройка алиасов для импортов
|
- **paths**: Настройка алиасов для импортов
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"@/*": ["./src/*"],
|
||||||
"*": ["./src/*"]
|
"*": ["./src/*"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
Позволяет импортировать файлы из `src` без указания полного пути
|
Позволяет импортировать файлы из `src` с использованием `@/` или без префикса
|
||||||
|
|
||||||
|
### Типы
|
||||||
|
- **types**: Глобальные типы для проекта
|
||||||
|
```json
|
||||||
|
["node", "jest", "@testing-library/jest-dom"]
|
||||||
|
```
|
||||||
|
- `node` - типы Node.js API
|
||||||
|
- `jest` - типы для Jest тестов
|
||||||
|
- `@testing-library/jest-dom` - типы для дополнительных матчеров Jest
|
||||||
|
|
||||||
## Настройки ts-node
|
## Настройки ts-node
|
||||||
|
|
||||||
@@ -71,10 +81,13 @@ pnpm type-check
|
|||||||
### Использование алиасов путей
|
### Использование алиасов путей
|
||||||
```typescript
|
```typescript
|
||||||
// Вместо:
|
// Вместо:
|
||||||
import { Button } from '../../../components/Button'
|
import { Button } from '../../../shared/ui/Button'
|
||||||
|
|
||||||
// Можно писать:
|
// Можно писать с @ алиасом:
|
||||||
import { Button } from 'components/Button'
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
|
||||||
|
// Или без префикса (для совместимости):
|
||||||
|
import { Button } from 'shared/ui/Button'
|
||||||
```
|
```
|
||||||
|
|
||||||
### JSX без импорта React
|
### JSX без импорта React
|
||||||
|
|||||||
Reference in New Issue
Block a user