277 lines
9.0 KiB
TypeScript
277 lines
9.0 KiB
TypeScript
import { useEffect, useReducer, useRef, useState } from 'react';
|
||
import { MiniTitle } from '../../ui/MiniTitle';
|
||
import { useWindowWidth } from '../../hooks/useWindowWidth';
|
||
import { Title } from '../../ui/Title';
|
||
import { useSwipeable } from 'react-swipeable';
|
||
import { ArrowLeftIcon } from '../icons/ArrowLeftIcon';
|
||
import { ArrowRightIcon } from '../icons/ArrowRightIcon';
|
||
import { PauseIcon } from '../icons/PauseIcon';
|
||
import { PlayIcon } from '../icons/PlayIcon';
|
||
import useStateRef from 'react-usestateref';
|
||
|
||
enum Media {
|
||
video,
|
||
img,
|
||
}
|
||
|
||
interface IProject<TMedia extends Media> {
|
||
src: TMedia extends Media.img ? string : string[];
|
||
title: string;
|
||
tags: string[];
|
||
media?: TMedia;
|
||
}
|
||
|
||
export function Projects() {
|
||
return (
|
||
<div
|
||
id="projects"
|
||
className="lg:py-[70px] lg:px-10 py-14 sm:px-6 px-4 overflow-hidden"
|
||
>
|
||
<Title className="2xl:mb-[77px] lg:mb-14 mb-6">
|
||
<span className="text-gradient">Большой опыт в работе</span> с
|
||
промышленными предприятиями и учебными заведениями
|
||
</Title>
|
||
<MiniTitle text="реализованные проекты" />
|
||
<Slider
|
||
projects={[
|
||
{
|
||
src: 'src/assets/projects/loader.png',
|
||
tags: ['Симулятор'],
|
||
title: 'Симулятор погрузчика',
|
||
media: Media.img,
|
||
},
|
||
{
|
||
src: [
|
||
'src/assets/projects/operator.mp4',
|
||
'src/assets/projects/operator.png',
|
||
],
|
||
tags: ['Симулятор', 'VR-приложение'],
|
||
title: 'Обучение работе с системой водоочистки',
|
||
media: Media.video,
|
||
} as IProject<Media.video>,
|
||
{
|
||
src: 'src/assets/projects/plane.png',
|
||
tags: ['Симулятор', 'VR-приложение'],
|
||
title: 'L 410 NG Aircraft',
|
||
media: Media.img,
|
||
},
|
||
{
|
||
src: 'src/assets/projects/hangar.png',
|
||
tags: ['Симулятор', 'VR-приложение'],
|
||
title: 'Сборка-разборка вертолётного двигателя',
|
||
media: Media.img,
|
||
},
|
||
{
|
||
src: 'src/assets/projects/trains.png',
|
||
tags: ['Симулятор', 'VR-приложение'],
|
||
title: 'Тренажер РЖД: ЭП2Д, Иволга, ЭП20, ТЭ33А, ТЭМ2',
|
||
media: Media.img,
|
||
},
|
||
{
|
||
src: 'src/assets/projects/laboratory.png',
|
||
tags: ['Симулятор', 'VR-приложение'],
|
||
title: 'Учебная лаборатория определения жирности молока',
|
||
media: Media.img,
|
||
},
|
||
{
|
||
src: 'src/assets/projects/train_big.jpg',
|
||
tags: ['Симулятор'],
|
||
title: 'Симулятор машиниста',
|
||
media: Media.img,
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Project<T extends Media>({ src, title, tags, media }: IProject<T>) {
|
||
const [buffering, setBuffering] = useState(true);
|
||
const [playing, setPlaying, playingRef] = useStateRef(false);
|
||
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
|
||
useEffect(() => console.log(buffering), [buffering]);
|
||
useEffect(() => console.log(playingRef), [playingRef]);
|
||
useEffect(() => console.log(playing), [playing]);
|
||
|
||
return (
|
||
<div className="bg-[#3D425C] bg-opacity-50 rounded-2xl aspect-[624/462] lg:min-w-[39vw] sm:min-w-[67vw] min-w-[calc(100vw-32px)] lg:translate-x-[calc(15.5vw-32px)] flex flex-col relative">
|
||
{media === Media.img ? (
|
||
<div
|
||
className="bg-cover bg-center bg-no-repeat min-h-[340px] flex-1 rounded-t-2xl"
|
||
style={{ backgroundImage: `url(${src})` }}
|
||
/>
|
||
) : (
|
||
<div
|
||
className="flex-1 rounded-t-2xl overflow-hidden relative flex justify-center items-center group bg-cover bg-center bg-no-repeat"
|
||
style={{ backgroundImage: `url(${src[1]})` }}
|
||
>
|
||
<video
|
||
src={src[0]}
|
||
ref={videoRef}
|
||
muted
|
||
loop
|
||
className="rounded-t-2xl object-cover aspect-[495/340]"
|
||
onPlaying={() => setBuffering(false)}
|
||
onWaiting={() => setBuffering(true)}
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
if (playingRef.current) {
|
||
videoRef.current?.pause();
|
||
setPlaying(false);
|
||
} else {
|
||
videoRef.current?.play();
|
||
setPlaying(true);
|
||
}
|
||
}}
|
||
className={
|
||
'absolute p-4 rounded-full border border-white group-hover:block ' +
|
||
(!buffering && playing ? 'hidden' : 'block')
|
||
}
|
||
>
|
||
{!buffering && playing ? (
|
||
<PauseIcon className="w-4 h-4" />
|
||
) : (
|
||
<PlayIcon className="w-4 h-4" />
|
||
)}
|
||
</button>
|
||
{buffering && <div className="rounded-t-2xl absolute" />}
|
||
</div>
|
||
)}
|
||
<div className="p-5 flex flex-col justify-between gap-3">
|
||
<h4 className="font-medium h4">{title}</h4>
|
||
<div className="flex gap-2">
|
||
{tags.map(tag => (
|
||
<p
|
||
key={tag}
|
||
className="opacity-80 font-medium rounded-3xl sm:py-3 py-2 px-4 border border-[#798FFF] m-text"
|
||
>
|
||
{tag}
|
||
</p>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Slider({ projects }: { projects: IProject<Media>[] }) {
|
||
const width = useWindowWidth();
|
||
const baseOffset =
|
||
width >= 1024 ? width * 0.39 : width >= 640 ? width * 0.67 : width - 20;
|
||
const [sliderOffset, setSliderOffset] = useState(-baseOffset);
|
||
const [slide, setSlide] = useState(0);
|
||
const [isAnimating, setIsAnimating] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
const [order, dispatch] = useReducer(
|
||
(state: typeof projects, action: string) => {
|
||
switch (action) {
|
||
case 'prev':
|
||
setSliderOffset(prev => prev - baseOffset);
|
||
return [state[state.length - 5], ...state.slice(0, -1)];
|
||
case 'next':
|
||
setSliderOffset(prev => prev + baseOffset);
|
||
return [...state.slice(1), state[4]];
|
||
default:
|
||
return state;
|
||
}
|
||
},
|
||
[
|
||
projects[projects.length - 2],
|
||
projects[projects.length - 1],
|
||
...projects,
|
||
projects[0],
|
||
projects[1],
|
||
],
|
||
);
|
||
|
||
const handlers = useSwipeable({
|
||
onSwipedLeft: () => {
|
||
if (!isAnimating) {
|
||
setIsAnimating(true);
|
||
setSlide(prev => (prev === order.length - 5 ? 0 : prev + 1));
|
||
dispatch('next');
|
||
}
|
||
},
|
||
onSwipedRight: () => {
|
||
if (!isAnimating) {
|
||
setIsAnimating(true);
|
||
setSlide(prev => (prev === 0 ? order.length - 5 : prev - 1));
|
||
dispatch('prev');
|
||
}
|
||
},
|
||
trackMouse: true,
|
||
preventScrollOnSwipe: true,
|
||
touchEventOptions: { passive: false },
|
||
});
|
||
|
||
useEffect(() => {
|
||
setSliderOffset(-baseOffset * 2);
|
||
}, [order, baseOffset, slide]);
|
||
|
||
useEffect(() => {
|
||
const refValue = ref.current;
|
||
refValue?.addEventListener('transitionend', () => {
|
||
setIsAnimating(false);
|
||
});
|
||
return () =>
|
||
refValue?.removeEventListener('transitionend', () =>
|
||
setIsAnimating(false),
|
||
);
|
||
}, [sliderOffset, order, slide]);
|
||
|
||
return (
|
||
<div className="flex flex-col lg:mt-4 sm:mt-3 mt-2">
|
||
<div {...handlers}>
|
||
<div
|
||
ref={ref}
|
||
className="flex sm:gap-4 gap-2 overflow-visible relative mb-[18px] -mr-10 select-none"
|
||
style={{
|
||
transition: `${sliderOffset === -baseOffset || sliderOffset === -baseOffset * 3 ? 0 : 0.5}s`,
|
||
transform: `translateX(${sliderOffset}px)`,
|
||
}}
|
||
>
|
||
{order.map((project, index) => (
|
||
<Project key={index} {...project} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4 lg:w-[clamp(720px,100vw-465px,1135px)] 2xl:w-[70.9vw] w-full self-start lg:ml-[15.5vw]">
|
||
<button
|
||
onClick={() => {
|
||
if (!isAnimating) {
|
||
setIsAnimating(true);
|
||
setSlide(prev => (prev === 0 ? order.length - 5 : prev - 1));
|
||
dispatch('prev');
|
||
}
|
||
}}
|
||
className="max-sm:hidden outline-none"
|
||
>
|
||
<ArrowLeftIcon />
|
||
</button>
|
||
<div className="h-1 bg-[#3D425C] w-full">
|
||
<div
|
||
className="bg-[#ffffff] h-1 duration-500"
|
||
style={{ width: `${((slide + 1) / projects.length) * 100}%` }}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
if (!isAnimating) {
|
||
setIsAnimating(true);
|
||
setSlide(prev => (prev === order.length - 5 ? 0 : prev + 1));
|
||
dispatch('next');
|
||
}
|
||
}}
|
||
className="max-sm:hidden outline-none"
|
||
>
|
||
<ArrowRightIcon />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|