From e6327ba6b830121e9570e8123b620d348957fd14 Mon Sep 17 00:00:00 2001 From: Batyr Date: Tue, 20 May 2025 18:14:26 +0500 Subject: [PATCH] added new page --- src/components/shared/index.ts | 3 + src/components/shared/media-modal.tsx | 122 +++++++++++++++ src/components/shared/participant-item.tsx | 116 +++++++++++++++ src/components/shared/tabs.tsx | 163 +++++++++++++++++++++ src/hooks/tanstack/use-participants.ts | 15 ++ src/hooks/tanstack/use-photos.ts | 12 ++ src/hooks/tanstack/use-videos.ts | 12 ++ src/locales/en/nav.json | 4 +- src/locales/ru/nav.json | 4 +- src/main.tsx | 6 + src/pages/media.tsx | 134 +++++++++++++++++ src/pages/participants.tsx | 88 +++++++++++ src/services/service.ts | 25 ++++ src/services/types/participants.type.ts | 53 +++++++ src/services/types/photo.type.ts | 49 +++++++ src/services/types/videos.type.ts | 38 +++++ 16 files changed, 840 insertions(+), 4 deletions(-) create mode 100644 src/components/shared/media-modal.tsx create mode 100644 src/components/shared/participant-item.tsx create mode 100644 src/components/shared/tabs.tsx create mode 100644 src/hooks/tanstack/use-participants.ts create mode 100644 src/hooks/tanstack/use-photos.ts create mode 100644 src/hooks/tanstack/use-videos.ts create mode 100644 src/pages/media.tsx create mode 100644 src/pages/participants.tsx create mode 100644 src/services/types/participants.type.ts create mode 100644 src/services/types/photo.type.ts create mode 100644 src/services/types/videos.type.ts diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 62e7af8..ee3a51c 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -12,6 +12,9 @@ export { LangMenu } from "./lang-menu"; export { Menu } from "./menu"; export { Chevron } from "./chevron"; export { Loader } from "./loader"; +export { Tabs } from "./tabs"; +export { ParticipantItem } from "./participant-item"; +export { MediaModal } from "./media-modal"; export * from "./about"; export * from "./home"; diff --git a/src/components/shared/media-modal.tsx b/src/components/shared/media-modal.tsx new file mode 100644 index 0000000..add1cbc --- /dev/null +++ b/src/components/shared/media-modal.tsx @@ -0,0 +1,122 @@ +import { FC, useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { ChevronLeftIcon, ChevronRightIcon, X } from "lucide-react"; +import { motion } from "motion/react"; +import useEmblaCarousel from "embla-carousel-react"; +import { useScrollLock } from "usehooks-ts"; +import { usePhotos } from "@/hooks/tanstack/use-photos"; +import { useVideos } from "@/hooks/tanstack/use-videos"; + +interface Props { + setIsOpen: (isOpen: boolean) => void; + activeItem: { id: number; type: string }; + setActiveItem: ({ id, type }: { id: number; type: "string" }) => void; + className?: string; +} + +export const MediaModal: FC = ({ className, setIsOpen, activeItem }) => { + const [emblaRef, emblaApi] = useEmblaCarousel({ + align: "center", + loop: false, + dragFree: false, + slidesToScroll: 1, + }); + + const { data } = usePhotos(1); + const { data: videos } = useVideos(1); + + useScrollLock(); + + const [canScrollPrev, setCanScrollPrev] = useState(false); + const [canScrollNext, setCanScrollNext] = useState(false); + + useEffect(() => { + if (!emblaApi) return; + + const onSelect = () => { + setCanScrollPrev(emblaApi.canScrollPrev()); + setCanScrollNext(emblaApi.canScrollNext()); + }; + + emblaApi.on("select", onSelect); + onSelect(); + + return () => { + emblaApi.off("select", onSelect); + }; + }, [emblaApi]); + + useEffect(() => { + if (!emblaApi || !data) return; + + emblaApi.scrollTo(activeItem.id, true); + }, [emblaApi, data, activeItem.id]); + + const slides = + activeItem.type === "photo" + ? data?.photos?.map((item) => ( +
+ {item.photo.file_name} +
+ )) + : videos?.videos?.map((item) => ( +
+
+ )); + + return ( + + setIsOpen(false)} + className="absolute top-10 right-6 p-3 md:size-20 size-16 cursor-pointer z-50 text-white hover:scale-110 transition-all duration-300" + /> + { +
+ canScrollPrev && emblaApi?.scrollPrev()} + className={cn( + "absolute left-10 top-1/2 -translate-y-1/2 text-white size-20 cursor-pointer hover:scale-110 transition-all duration-300 z-40", + !canScrollPrev && "opacity-20 pointer-events-none" + )} + /> + canScrollNext && emblaApi?.scrollNext()} + className={cn( + "absolute right-10 top-1/2 -translate-y-1/2 text-white size-20 cursor-pointer hover:scale-110 transition-all duration-300 z-40", + !canScrollNext && "opacity-20 pointer-events-none" + )} + /> +
+ } + +
+
{slides}
+
+
+ ); +}; diff --git a/src/components/shared/participant-item.tsx b/src/components/shared/participant-item.tsx new file mode 100644 index 0000000..d7bbb91 --- /dev/null +++ b/src/components/shared/participant-item.tsx @@ -0,0 +1,116 @@ +import { FC } from "react"; +import { cn } from "@/lib/utils"; +import { useLangStore } from "@/store/lang"; + +interface Props { + className?: string; + index: number; + name: string; + image?: { + path: string; + }; + image_country: { + path?: string; + }; + country: string; + about: string; + arr: number; +} + +export const ParticipantItem: FC = ({ + className, + index, + name, + image, + image_country, + country, + about, + arr, +}) => { + const lang = useLangStore((state) => state.lang); + + return ( + <> + {/* MOBILE */} +
+
+
+
+

+ {lang === "ru" ? "Название" : "Company"}: +

+

{name}

+
+
+ +
+
+
+

+ {lang === "ru" ? "Страна" : "Country"}: +

+ +
+ flag +

{country}

+
+
+
+
+ +
+
+

+ {lang === "ru" ? "Сфера" : "Industry"}: +

+

{about}

+
+
+
+ +
+ company +
+
+ {/* DESKTOP */} + + + ); +}; diff --git a/src/components/shared/tabs.tsx b/src/components/shared/tabs.tsx new file mode 100644 index 0000000..4cad762 --- /dev/null +++ b/src/components/shared/tabs.tsx @@ -0,0 +1,163 @@ +import { FC, useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import useEmblaCarousel from "embla-carousel-react"; +import { useMediaQuery } from "usehooks-ts"; +import { useLangStore } from "@/store/lang"; + +const tabs = [ + { id: 0, title: "Все компании", titleEn: "All companies" }, + { + id: 3, + title: "Государственные учреждения ", + titleEn: "Government Institutions", + }, + { + id: 1, + title: "Местные компании", + titleEn: "Local companies", + }, + { + id: 2, + title: "Иностранные компании", + titleEn: "Foreign companies", + }, +]; + +interface Props { + className?: string; + state: number; + setState: (val: number) => void; + data?: typeof tabs; +} + +export const Tabs: FC = ({ + className, + setState, + state, + data = tabs, +}) => { + const lang = useLangStore((state) => state.lang); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 }); + const [emblaRef, emblaApi] = useEmblaCarousel( + { + axis: "x", + align: "start", + containScroll: "trimSnaps", + }, + [] + ); + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); + const containerRef = useRef(null); + + const updateIndicator = useCallback(() => { + const activeTabElement = tabRefs.current[state]; + if (!activeTabElement) return; + + if (isDesktop) { + setIndicatorStyle({ + left: activeTabElement.offsetLeft, + width: activeTabElement.offsetWidth, + }); + } else if (emblaApi) { + const emblaNode = emblaApi.rootNode(); + const emblaContainer = emblaApi.containerNode(); + if (!emblaNode || !emblaContainer) return; + + const scrollLeft = emblaContainer.scrollLeft; + const tabLeft = activeTabElement.offsetLeft; + + setIndicatorStyle({ + left: tabLeft - scrollLeft, + width: activeTabElement.offsetWidth, + }); + } + }, [state, isDesktop, emblaApi]); + + useEffect(() => { + if (!isDesktop && emblaApi) return; + + emblaApi?.scrollTo(state); + + updateIndicator(); + }, [emblaApi, state, updateIndicator, isDesktop]); + + useEffect(() => { + if (!emblaApi) return; + + updateIndicator(); + + emblaApi.on("scroll", updateIndicator); + emblaApi.on("resize", updateIndicator); + + return () => { + emblaApi.off("scroll", updateIndicator); + emblaApi.off("resize", updateIndicator); + }; + }, [emblaApi, updateIndicator]); + + const handleTabClick = useCallback( + (index: number) => { + setState(index); + if (!isDesktop && emblaApi) { + emblaApi.scrollTo(index); + } + }, + [emblaApi, setState, isDesktop] + ); + + return ( +
+ {isDesktop ? ( +
+ {data?.map((tab, index) => ( + + ))} +
+ ) : ( +
+
+ {data?.map((tab, index) => ( + + ))} +
+
+ )} + +
+
+ ); +}; diff --git a/src/hooks/tanstack/use-participants.ts b/src/hooks/tanstack/use-participants.ts new file mode 100644 index 0000000..68f22f1 --- /dev/null +++ b/src/hooks/tanstack/use-participants.ts @@ -0,0 +1,15 @@ +import { getParticipants } from "@/services/service"; +import { useLangStore } from "@/store/lang"; +import { useQuery } from "@tanstack/react-query"; + +export const useParticipants = () => { + const lang = useLangStore((state) => state.lang); + + const { data, isPending } = useQuery({ + queryKey: ["participants", lang], + queryFn: () => getParticipants(lang), + select: ({ data }) => data.data, + }); + + return { data, isPending }; +}; diff --git a/src/hooks/tanstack/use-photos.ts b/src/hooks/tanstack/use-photos.ts new file mode 100644 index 0000000..5664475 --- /dev/null +++ b/src/hooks/tanstack/use-photos.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPhotos } from "@/services/service"; + +export const usePhotos = (id: number) => { + const { data, isPending, isError } = useQuery({ + queryKey: ["photos", id], + queryFn: () => getPhotos(id), + select: ({ data }) => data.data, + }); + + return { data, isPending, isError }; +}; diff --git a/src/hooks/tanstack/use-videos.ts b/src/hooks/tanstack/use-videos.ts new file mode 100644 index 0000000..7bd92ee --- /dev/null +++ b/src/hooks/tanstack/use-videos.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { getVideos } from "@/services/service"; + +export const useVideos = (id: number) => { + const { data, isPending, isError } = useQuery({ + queryKey: ["videos", id], + queryFn: () => getVideos(id), + select: ({ data }) => data.data, + }); + + return { data, isPending, isError }; +}; diff --git a/src/locales/en/nav.json b/src/locales/en/nav.json index 619dd41..c02d4d5 100644 --- a/src/locales/en/nav.json +++ b/src/locales/en/nav.json @@ -25,7 +25,7 @@ { "text": "About exhibition", "link": "/about" }, { "text": "Media", - "link": "" + "link": "/media" } ] }, @@ -36,7 +36,7 @@ { "text": "Why visit?", "link": "" }, { "text": "List of Participants", - "link": "" + "link": "/participants" }, { "text": "Programme", diff --git a/src/locales/ru/nav.json b/src/locales/ru/nav.json index 39d5a79..0e0b7e0 100644 --- a/src/locales/ru/nav.json +++ b/src/locales/ru/nav.json @@ -24,7 +24,7 @@ { "text": "О выставке", "link": "/about" }, { "text": "Медиа", - "link": "" + "link": "/media" } ] }, @@ -35,7 +35,7 @@ { "text": "Почему стоит посетить?", "link": "" }, { "text": "Список участников", - "link": "" + "link": "/participants" }, { "text": "Программа", diff --git a/src/main.tsx b/src/main.tsx index 8276966..f702b93 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,6 +21,10 @@ const News = lazy(() => import(/* webpackPrefetch: true */ "./pages/news")); const NewsInner = lazy( () => import(/* webpackPrefetch: true */ "./pages/news-inner") ); +const Participants = lazy( + () => import(/* webpackPrefetch: true */ "./pages/participants") +); +const Media = lazy(() => import(/* webpackPrefetch: true */ "./pages/media")); const router = createBrowserRouter([ { @@ -35,6 +39,8 @@ const router = createBrowserRouter([ { element: , path: "/contacts" }, { element: , path: "/news" }, { element: , path: "/news/:id" }, + { element: , path: "/participants" }, + { element: , path: "/media" }, ], }, ]); diff --git a/src/pages/media.tsx b/src/pages/media.tsx new file mode 100644 index 0000000..07efcda --- /dev/null +++ b/src/pages/media.tsx @@ -0,0 +1,134 @@ +import { FC, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useLangStore } from "@/store/lang"; +import { Loader, MediaModal, Tabs } from "@/components/shared"; +import { AnimatePresence } from "motion/react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Play } from "lucide-react"; +import { Container, Cover } from "@/components/layout"; +import { usePhotos } from "@/hooks/tanstack/use-photos"; +import { useVideos } from "@/hooks/tanstack/use-videos"; + +interface Props { + className?: string; +} + +const momentsTabs = [ + { + id: 0, + title: "Фото", + titleEn: "Photo", + }, + { + id: 1, + title: "Видео", + titleEn: "Video", + }, +]; + +const Media: FC = ({ className }) => { + const [state, setState] = useState(0); + const { data, isPending } = usePhotos(1); + const { data: videos } = useVideos(1); + const [isModalOpen, setIsModalOpen] = useState(false); + const [activeItem, setActiveItem] = useState({ id: 0, type: "photo" }); + + const lang = useLangStore((state) => state.lang); + + const onItem = ({ id, type }: { id: number; type: string }) => { + setIsModalOpen(true); + setActiveItem({ id, type }); + }; + + console.log(videos); + const { t } = useTranslation("main"); + + const [isCollapse, setIsCollapse] = useState(false); + + return ( + <> + + {isModalOpen && ( + + )} + + +
+ + + + {isPending ? ( + + ) : ( +
+ +

2025

+
+ {state === 0 + ? data?.photos + ?.slice(0, isCollapse ? 1000 : 16) + ?.map((photo, i) => ( +
onItem({ id: i, type: "photo" })} + key={i} + className="cursor-pointer embla__slide basis-1/1 overflow-hidden" + > + {photo?.photo?.file_name +
+ )) + : videos?.videos?.map((video, i) => ( +
onItem({ id: i, type: "video" })} + key={i} + className="cursor-pointer group embla__slide basis-1/1 overflow-hidden relative" + > + +
+ +
+ ))} +
+ + {data?.photos?.length && + data?.photos?.length > 16 && + !isCollapse && ( + + )} +
+ )} + +
+ + ); +}; + +export default Media; diff --git a/src/pages/participants.tsx b/src/pages/participants.tsx new file mode 100644 index 0000000..27cde55 --- /dev/null +++ b/src/pages/participants.tsx @@ -0,0 +1,88 @@ +import { FC, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useLangStore } from "@/store/lang"; +import { Container } from "@/components/layout"; +import { Loader, ParticipantItem, Tabs } from "@/components/shared"; +import { useParticipants } from "@/hooks/tanstack/use-participants"; + +interface Props { + className?: string; +} + +const Participants: FC = ({ className }) => { + const lang = useLangStore((state) => state.lang); + + const { data, isPending } = useParticipants(); + + const dataHeader = { + company: lang === "ru" ? "Название компании" : "Company name", + country: lang === "ru" ? "Страна" : "Country", + industry: lang === "ru" ? "Сфера деятельности" : "Industry", + }; + + const [activeTab, setActiveTab] = useState(0); + + const splitedData = data?.[0]?.participants.concat( + data?.[1]?.participants, + data?.[2]?.participants + ); + + const filteredData = data?.filter((item) => + activeTab === 1 + ? item.id === 3 + : activeTab === 2 + ? item.id === 1 + : item.id === 2 + ); + + console.log(activeTab); + + return ( + +
+

+ {lang === "ru" ? "Список участников" : "List of participants"} +

+ + +
+
+

{dataHeader?.company}

+

{dataHeader?.country}

+

{dataHeader?.industry}

+
+ + {isPending ? ( + + ) : activeTab === 0 ? ( + splitedData?.map((item, index, arr) => ( + + )) + ) : ( + filteredData?.[0]?.participants?.map((item, index, arr) => ( + + )) + )} +
+
+
+ ); +}; + +export default Participants; diff --git a/src/services/service.ts b/src/services/service.ts index 193191a..f66e3e3 100644 --- a/src/services/service.ts +++ b/src/services/service.ts @@ -11,6 +11,9 @@ import { IndustriesType } from "@/hooks/tanstack/use-industries"; import { TimeType } from "@/hooks/tanstack/use-exhibition-time"; import { PartnersType } from "@/hooks/tanstack/use-partners"; import { NewsInnerType, NewsType } from "./types/news.type"; +import { ParticipantsType } from "./types/participants.type"; +import { PhotoTypes } from "./types/photo.type"; +import { VideoTypes } from "./types/videos.type"; const axios_url = axios.create({ baseURL: "https://turkmentextile.turkmenexpo.com/app/api/v1/", @@ -135,3 +138,25 @@ export const getNewsInner = async (id: number, lang: string) => { return data; }; + +export const getParticipants = async (lang: LangState["lang"]) => { + const data = axios_url("participants", { + headers: { + "Accept-Language": lang, + }, + }); + + return data; +}; + +export const getPhotos = async (id: number) => { + const data = axios_url("photos/category/" + id); + + return data; +}; + +export const getVideos = async (id: number) => { + const data = axios_url("videos/category/" + id); + + return data; +}; diff --git a/src/services/types/participants.type.ts b/src/services/types/participants.type.ts new file mode 100644 index 0000000..4f84b88 --- /dev/null +++ b/src/services/types/participants.type.ts @@ -0,0 +1,53 @@ +export interface ParticipantsType { + status: string; + data: Datum[]; +} + +export interface Datum { + id: number; + name: string; + created_at: Date; + updated_at: Date; + participants: Participant[]; +} + +export interface Participant { + id: number; + name: string; + about: string; + country: string; + participant_category_id: number; + created_at: Date; + updated_at: Date; + image: Image | null; + image_country: Image | null; +} + +export interface Image { + id: number; + disk_name: string; + file_name: string; + file_size: number; + content_type: ContentType; + title: null; + description: null; + field: Field; + sort_order: number; + created_at: Date; + updated_at: Date; + path: string; + extension: Extension; +} + +export enum ContentType { + ImagePNG = "image/png", +} + +export enum Extension { + PNG = "png", +} + +export enum Field { + Image = "image", + ImageCountry = "image_country", +} diff --git a/src/services/types/photo.type.ts b/src/services/types/photo.type.ts new file mode 100644 index 0000000..1f4a8f0 --- /dev/null +++ b/src/services/types/photo.type.ts @@ -0,0 +1,49 @@ +export interface PhotoTypes { + status: string; + data: Data; +} + +export interface Data { + id: number; + name: string; + created_at: Date; + updated_at: Date; + photos: PhotoElement[]; +} + +export interface PhotoElement { + id: number; + category_photo_media_id: number; + name: string; + created_at: Date; + updated_at: Date; + photo: PhotoPhoto; +} + +export interface PhotoPhoto { + id: number; + disk_name: string; + file_name: string; + file_size: number; + content_type: ContentType; + title: null; + description: null; + field: Field; + sort_order: number; + created_at: Date; + updated_at: Date; + path: string; + extension: Extension; +} + +export enum ContentType { + ImageWebp = "image/webp", +} + +export enum Extension { + Webp = "webp", +} + +export enum Field { + Photo = "photo", +} diff --git a/src/services/types/videos.type.ts b/src/services/types/videos.type.ts new file mode 100644 index 0000000..f3ebe78 --- /dev/null +++ b/src/services/types/videos.type.ts @@ -0,0 +1,38 @@ +export interface VideoTypes { + status: string; + data: Data; +} + +export interface Data { + id: number; + name: string; + created_at: Date; + updated_at: Date; + videos: VideoElement[]; +} + +export interface VideoElement { + id: number; + name: string; + category_video_media_id: number; + created_at: Date; + updated_at: Date; + video: VideoPhotoClass; + video_photo: VideoPhotoClass; +} + +export interface VideoPhotoClass { + id: number; + disk_name: string; + file_name: string; + file_size: number; + content_type: string; + title: null; + description: null; + field: string; + sort_order: number; + created_at: Date; + updated_at: Date; + path: string; + extension: string; +}