diff --git a/client/package.json b/client/package.json index d41eb4d..428b4e3 100644 --- a/client/package.json +++ b/client/package.json @@ -50,6 +50,8 @@ "d3-array": "3.2.4", "date-fns": "^3.3.1", "deck.gl": "8.9.19", + "embla-carousel": "^8.3.0", + "embla-carousel-react": "^8.3.0", "eslint": "8.42.0", "eslint-config-next": "13.4.5", "framer-motion": "^10.16.4", diff --git a/client/src/components/map/constants.ts b/client/src/components/map/constants.ts index 88fd049..6d28eef 100644 --- a/client/src/components/map/constants.ts +++ b/client/src/components/map/constants.ts @@ -5,16 +5,18 @@ export const DEFAULT_VIEW_STATE = { pitch: 0, bearing: 0, padding: { - top: 50, - bottom: 50, - left: 50, - right: 50, + top: 0, + bottom: 0, + left: 0, + right: 0, }, }; +export const DEFAULT_MOBILE_ZOOM = 0.75; + export const DEFAULT_PROPS: CustomMapProps = { id: 'default', initialViewState: DEFAULT_VIEW_STATE, - minZoom: 1, + minZoom: DEFAULT_MOBILE_ZOOM, maxZoom: 14, }; diff --git a/client/src/components/map/index.tsx b/client/src/components/map/index.tsx index d75a4be..a61e00d 100644 --- a/client/src/components/map/index.tsx +++ b/client/src/components/map/index.tsx @@ -137,7 +137,7 @@ export const MapMapbox: FC = ({ }, [bounds, isFlying]); return ( -
+
| null)[]; handleClick: (id: string | number) => void; + handleClose?: () => void; }; -const Marker = ({ markers, handleClick }: MarkerProps) => { +const Marker = ({ markers, handleClick, handleClose }: MarkerProps) => { const { coordinates } = markers?.[0]?.geometry || {}; + + const isMobile = !useBreakpoint()('sm'); + if (!coordinates?.length) return null; - return ( - -
+ + const MARKER = () => ( +
+ -
- {markers?.map((marker) => { - if (!marker || !marker?.id) return null; - return ( -
e.stopPropagation()} - > -
- -

{marker?.properties?.categoryName}

-
-

{marker?.properties?.title}

-

- {marker?.properties?.location} -

- +
+ {markers?.map((marker) => { + if (!marker || !marker?.id) return null; + return ( +
e.stopPropagation()} + > +
+ +

{marker?.properties?.categoryName}

- ); - })} -
+

{marker?.properties?.title}

+

+ {marker?.properties?.location} +

+ + +
+ ); + })}
+
+ ); + + return isMobile ? ( +
+ +
+ ) : ( + + ); }; diff --git a/client/src/components/map/legend/index.tsx b/client/src/components/map/legend/index.tsx index 25f2ec4..c18675e 100644 --- a/client/src/components/map/legend/index.tsx +++ b/client/src/components/map/legend/index.tsx @@ -4,21 +4,20 @@ import { ChevronDown } from 'lucide-react'; import { cn } from '@/lib/classnames'; +import { useBreakpoint } from '@/hooks/screen-size'; + import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import SortableList from './sortable/list'; import { LegendProps } from './types'; -export const Legend: React.FC = ({ - children, - className = '', - sortable, - onChangeOrder, -}: LegendProps) => { +export const Legend: React.FC = ({ children, className = '' }: LegendProps) => { const isChildren = useMemo(() => { return !!Children.count(Children.toArray(children).filter((c) => isValidElement(c))); }, [children]); + const breakpoint = useBreakpoint(); + const isMobile = !breakpoint('sm'); + return ( isChildren && (
= ({ > {isChildren && (
- {!!sortable?.enabled && !!onChangeOrder ? ( - + + + Legend + + {children} - - ) : Array.isArray(children) && children.length > 1 ? ( - - - Legend - - - {children} - - - ) : ( -
{children}
- )} + +
)}
diff --git a/client/src/components/ui/carousel/index.css b/client/src/components/ui/carousel/index.css new file mode 100644 index 0000000..7461e9b --- /dev/null +++ b/client/src/components/ui/carousel/index.css @@ -0,0 +1,115 @@ + +.embla { + margin: auto; + --slide-height: 19rem; + --slide-spacing: 2rem; + --slide-size: 80%; +} +.embla__viewport { + overflow: hidden; +} +.embla__container { + display: flex; + touch-action: pan-y pinch-zoom; + /* margin-left: calc(var(--slide-spacing) * -1); */ +} +.embla__slide { + transform: translate3d(0, 0, 0); + /* flex: 0 0 var(--slide-size); */ + min-width: 0; + /* padding-left: var(--slide-spacing); */ +} + +.embla__slide__number { + box-shadow: inset 0 0 0 0.2rem black; + border-radius: 1.8rem; + font-size: 4rem; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + height: var(--slide-height); + user-select: none; +} +.embla__controls { + display: grid; + grid-template-columns: auto 1fr; + justify-content: space-between; + gap: 1.2rem; + margin-top: 1.8rem; +} +.embla__buttons { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.6rem; + align-items: center; +} +.embla__button { + -webkit-tap-highlight-color: red; + -webkit-appearance: none; + appearance: none; + background-color: transparent; + touch-action: manipulation; + display: inline-flex; + text-decoration: none; + cursor: pointer; + border: 0; + padding: 0; + margin: 0; + box-shadow: inset 0 0 0 0.2rem gray; + width: 3.6rem; + height: 3.6rem; + z-index: 1; + border-radius: 50%; + color: white; + display: flex; + align-items: center; + justify-content: center; +} +.embla__button:disabled { + color: var(--detail-high-contrast); +} +.embla__button__svg { + width: 35%; + height: 35%; +} +.embla__dots { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 16px; + margin-right: calc((2.6rem - 1.4rem) / 2 * -1); +} +.embla__dot { + -webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5); + -webkit-appearance: none; + appearance: none; + /* background-color: white; */ + border: 1px solid white; + touch-action: manipulation; + display: inline-flex; + text-decoration: none; + cursor: pointer; + /* border: 0; */ + padding: 0; + margin: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} +.embla__dot:after { + box-shadow: inset 0 0 0 0.2rem var(--detail-medium-contrast); + width: 1.4rem; + height: 1.4rem; + border-radius: 50%; + display: flex; + align-items: center; + content: ''; +} +.embla__dot--selected:after { + box-shadow: inset 0 0 0 0.2rem var(--text-body); +} diff --git a/client/src/components/ui/carousel/index.tsx b/client/src/components/ui/carousel/index.tsx new file mode 100644 index 0000000..6dd1f38 --- /dev/null +++ b/client/src/components/ui/carousel/index.tsx @@ -0,0 +1,271 @@ +import './index.css'; +import React, { + ComponentPropsWithRef, + PropsWithChildren, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; +import useEmblaCarousel from 'embla-carousel-react'; +import Image from 'next/image'; +import { cn } from '@/lib/classnames'; +import { getImageSrc } from '@/lib/image-src'; +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; + +type UseDotButtonType = { + selectedIndex: number; + scrollSnaps: number[]; + onDotButtonClick: (index: number) => void; +}; + +export const useDotButton = (emblaApi: EmblaCarouselType | undefined): UseDotButtonType => { + const [selectedIndex, setSelectedIndex] = useState(0); + const [scrollSnaps, setScrollSnaps] = useState([]); + + const onDotButtonClick = useCallback( + (index: number) => { + if (!emblaApi) return; + emblaApi.scrollTo(index); + }, + [emblaApi] + ); + + const onInit = useCallback((emblaApi: EmblaCarouselType) => { + setScrollSnaps(emblaApi.scrollSnapList()); + }, []); + + const onSelect = useCallback((emblaApi: EmblaCarouselType) => { + setSelectedIndex(emblaApi.selectedScrollSnap()); + }, []); + + useEffect(() => { + if (!emblaApi) return; + + onInit(emblaApi); + onSelect(emblaApi); + emblaApi.on('reInit', onInit).on('reInit', onSelect).on('select', onSelect); + }, [emblaApi, onInit, onSelect]); + + return { + selectedIndex, + scrollSnaps, + onDotButtonClick, + }; +}; + +type DotButtonPropType = ComponentPropsWithRef<'button'>; + +export const DotButton: React.FC = (props) => { + const { children, ...restProps } = props; + + return ( + + ); +}; + +type UsePrevNextButtonsType = { + prevBtnDisabled: boolean; + nextBtnDisabled: boolean; + onPrevButtonClick: () => void; + onNextButtonClick: () => void; +}; + +export const usePrevNextButtons = ( + emblaApi: EmblaCarouselType | undefined +): UsePrevNextButtonsType => { + const [prevBtnDisabled, setPrevBtnDisabled] = useState(true); + const [nextBtnDisabled, setNextBtnDisabled] = useState(true); + + const onPrevButtonClick = useCallback(() => { + if (!emblaApi) return; + emblaApi.scrollPrev(); + }, [emblaApi]); + + const onNextButtonClick = useCallback(() => { + if (!emblaApi) return; + emblaApi.scrollNext(); + }, [emblaApi]); + + const onSelect = useCallback((emblaApi: EmblaCarouselType) => { + setPrevBtnDisabled(!emblaApi.canScrollPrev()); + setNextBtnDisabled(!emblaApi.canScrollNext()); + }, []); + + useEffect(() => { + if (!emblaApi) return; + + onSelect(emblaApi); + emblaApi.on('reInit', onSelect).on('select', onSelect); + }, [emblaApi, onSelect]); + + return { + prevBtnDisabled, + nextBtnDisabled, + onPrevButtonClick, + onNextButtonClick, + }; +}; + +type PrevButtonPropType = ComponentPropsWithRef<'button'>; + +export const PrevButton: React.FC = (props) => { + const { children, ...restProps } = props; + + return ( + + ); +}; + +export const NextButton: React.FC = (props) => { + const { children, ...restProps } = props; + + return ( + + ); +}; + +type CarouselMediaProps = { + media: { + id: number; + url: string; + mime: string; + type: string; + title: string; + }; + isCurrentMedia?: boolean; +}; + +export const CarouselMedia = ({ media, isCurrentMedia }: CarouselMediaProps) => { + const videoRef = useRef(null); + const mediaSrc = getImageSrc(media?.url); + + useEffect(() => { + if (!isCurrentMedia && videoRef.current) { + videoRef.current?.pause(); + } else if (isCurrentMedia && videoRef.current) { + videoRef.current?.play(); + } + }, [isCurrentMedia]); + + if (media?.type === 'video') { + return ( + + ); + } + return ( + {media.title} + ); +}; + +type PropType = PropsWithChildren & { + options?: EmblaOptionsType; + medias: CarouselMediaProps['media'][]; + selected?: number; +}; + +const EmblaCarousel: React.FC = ({ options, medias, selected }) => { + const [emblaRef, emblaApi] = useEmblaCarousel(options); + const { selectedIndex, scrollSnaps, onDotButtonClick } = useDotButton(emblaApi); + // const { prevBtnDisabled, nextBtnDisabled, onPrevButtonClick, onNextButtonClick } = + // usePrevNextButtons(emblaApi); + + const [currSlider, setCurrSlider] = useState(0); + + useEffect(() => { + if (selected !== undefined && emblaApi) { + emblaApi.scrollTo(selected); + setCurrSlider(selected); + } + }, [selected, emblaApi]); + + const handleSelectedSlide = useCallback((embla: EmblaCarouselType) => { + setCurrSlider(embla.selectedScrollSnap()); + }, []); + + useEffect(() => { + if (emblaApi) emblaApi.on('slidesInView', handleSelectedSlide); + }, [emblaApi, handleSelectedSlide]); + + return ( +
+
+
1 ? '-ml-4 sm:-ml-6' : '')}> + {medias?.map((media, index) => ( +
1 ? 'flex-[0_0_80%] pl-4 sm:pl-6' : 'flex-1' + )} + key={index} + > +
+ +
+
+ ))} +
+
+ +
+ {/*
+ +
*/} + +
+ {scrollSnaps.map((_, index) => ( + onDotButtonClick(index)} + className={cn( + 'h-4 w-4 rounded-full border border-white data-[selected=true]:bg-white/50', + index === selectedIndex ? ' embla__dot--selected' : '' + )} + /> + ))} +
+ + {/*
+ +
*/} +
+
+ ); +}; + +export default EmblaCarousel; diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx index 6b17695..5294cbf 100644 --- a/client/src/components/ui/dialog.tsx +++ b/client/src/components/ui/dialog.tsx @@ -46,7 +46,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close @@ -64,7 +64,7 @@ const DialogContentHome = React.forwardRef< { +type CategoriesProps = { + className?: string; +}; +const Categories = ({ className }: CategoriesProps) => { const { data, isError, isPlaceholderData, isFetched, isFetching } = useGetCategories(); const categories = data?.data; return ( -
+
{ {name} diff --git a/client/src/containers/globe/filters/index.tsx b/client/src/containers/globe/filters/index.tsx index ab3fdec..0a2ee22 100644 --- a/client/src/containers/globe/filters/index.tsx +++ b/client/src/containers/globe/filters/index.tsx @@ -16,6 +16,13 @@ import { useGetIfis } from '@/types/generated/ifi'; import { useGetTags } from '@/types/generated/tag'; import FilterItem from './item'; +import { useGetCategories } from '@/types/generated/category'; +import { useSyncCategory } from '@/store/globe'; +import { RadioGroup, RadioGroupItem } from '@radix-ui/react-radio-group'; +import { useBreakpoint } from '@/hooks/screen-size'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { DialogHeader } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; type FiltersProps = { filtersActive: boolean; @@ -24,7 +31,10 @@ type FiltersProps = { const Filters = ({ filtersActive }: FiltersProps) => { const { data: tagsData } = useGetTags({ 'pagination[limit]': 1000 }); const { data: ifisData } = useGetIfis({ 'pagination[limit]': 1000 }); + const { data: categoriesData } = useGetCategories(); + const breakpoint = useBreakpoint(); + const isMobile = !breakpoint('sm'); const filtersData = useMemo(() => { return [ { @@ -48,6 +58,16 @@ const Filters = ({ filtersActive }: FiltersProps) => { ]; }, [ifisData?.data, tagsData?.data]); + const [category, setCategory] = useSyncCategory(); + + const handleClick = (slug: string) => { + if (category === slug) { + setCategory(null); + return; + } + setCategory(slug); + }; + return (
@@ -65,19 +85,20 @@ const Filters = ({ filtersActive }: FiltersProps) => { - -
- - - - + + + +
+

+ Filters +

+
+ + + +
-
-

- Filters -

-

Filter stories on the globe by @@ -87,8 +108,45 @@ const Filters = ({ filtersActive }: FiltersProps) => { {filtersData.map((filter) => filter?.options?.length ? : null )} + + {isMobile && ( + <> +

+

+ Category +

+ +
+
+ + {categoriesData?.data?.map(({ id, attributes }) => { + if (attributes?.name && attributes?.slug) { + return ( + handleClick(e.currentTarget?.value)} + > + {attributes?.name} + + ); + } + return null; + })} + +
+ + )}
-
+
diff --git a/client/src/containers/globe/index.tsx b/client/src/containers/globe/index.tsx index 29ed85c..17caf18 100644 --- a/client/src/containers/globe/index.tsx +++ b/client/src/containers/globe/index.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; - +import { motion } from 'framer-motion'; import { useMap } from 'react-map-gl'; import { useSetAtom } from 'jotai'; @@ -14,9 +14,10 @@ import { useSyncFilters } from '@/store/globe'; import { layersAtom, tmpBboxAtom } from '@/store/map'; import { useSyncStep } from '@/store/stories'; +import { useBreakpoint } from '@/hooks/screen-size'; import useStories from '@/hooks/stories/useStories'; -import { DEFAULT_VIEW_STATE } from '@/components/map/constants'; +import { DEFAULT_MOBILE_ZOOM, DEFAULT_VIEW_STATE } from '@/components/map/constants'; import { Button } from '@/components/ui/button'; import Card from '@/components/ui/card'; import GradientLine from '@/components/ui/gradient-line'; @@ -46,8 +47,13 @@ export default function Home() { const storiesLength = storiesData?.data?.length; const { default: map } = useMap(); + const breakpoint = useBreakpoint(); + const isMobile = !breakpoint('sm'); + useEffect(() => { const bounds = new mapboxgl.LngLatBounds(); + map?.setPadding(DEFAULT_VIEW_STATE.padding); + storiesData?.data?.forEach(({ attributes }) => { if (!(attributes?.marker as StoryMarker)?.markers?.length) return; const { lat, lng } = (attributes?.marker as StoryMarker)?.markers?.[0] || {}; @@ -59,9 +65,16 @@ export default function Home() { setTmpBbox({ bbox: bounds.toArray().flat() as [number, number, number, number], - options: { ...DEFAULT_VIEW_STATE, latitude: center.lat, longitude: center.lng }, + options: { + ...DEFAULT_VIEW_STATE, + latitude: center.lat, + longitude: center.lng, + pitch: 0, + bearing: 0, + zoom: isMobile ? DEFAULT_MOBILE_ZOOM : DEFAULT_VIEW_STATE.zoom, + }, }); - }, [map, setTmpBbox, storiesData?.data]); + }, [isMobile, map, setTmpBbox, storiesData?.data]); useEffect(() => { setLayers([]); @@ -75,11 +88,11 @@ export default function Home() { const filtersActive = Object.values(filters).some((filter) => !!filter?.length); return ( -
+
-
-
-
+
+
+
@@ -98,11 +111,12 @@ export default function Home() {
-
+
-
+ {/* Desktop */} +
@@ -122,8 +136,32 @@ export default function Home() {
+ {/* Mobile */} +
-
+
diff --git a/client/src/containers/globe/search/index.tsx b/client/src/containers/globe/search/index.tsx index b98e798..fea12a4 100644 --- a/client/src/containers/globe/search/index.tsx +++ b/client/src/containers/globe/search/index.tsx @@ -12,7 +12,7 @@ const SearchStories = () => { }; return ( -
+
{ return ( About - -
+ +
About the Impact Sphere
@@ -18,7 +18,7 @@ const About = () => { habitasse sed eget semper pellentesque malesuada. Turpis.
-
+
contatcs diff --git a/client/src/containers/header/index.tsx b/client/src/containers/header/index.tsx index a741f4d..d05df29 100644 --- a/client/src/containers/header/index.tsx +++ b/client/src/containers/header/index.tsx @@ -9,42 +9,43 @@ import About from './about'; type HeaderProps = { pathname?: string; + className?: string; }; -const Header = ({ pathname }: HeaderProps) => { +const Header = ({ pathname, className }: HeaderProps) => { const isHome = pathname?.includes('home'); return ( -
-
+
+
-
+ - +
-
-

+
+

Impact Sphere

-
- +
+
diff --git a/client/src/containers/home/index.tsx b/client/src/containers/home/index.tsx index 5f55438..284af84 100644 --- a/client/src/containers/home/index.tsx +++ b/client/src/containers/home/index.tsx @@ -1,18 +1,22 @@ 'use client'; -import { useEffect, useState, useMemo, useCallback } from 'react'; +import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { useMap } from 'react-map-gl'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; -import { motion } from 'framer-motion'; +import { motion, useMotionValueEvent, useScroll } from 'framer-motion'; import { useAtomValue, useSetAtom } from 'jotai'; -import resolveConfig from 'tailwindcss/resolveConfig'; import { homeMarkerAtom } from '@/store/home'; +import { useBreakpoint } from '@/hooks/screen-size'; + +import { DEFAULT_MOBILE_ZOOM } from '@/components/map/constants'; import { Dialog, DialogContentHome } from '@/components/ui/dialog'; +import ScrollExplanation from '@/components/ui/scroll-explanation'; import Header from '../header'; @@ -20,22 +24,6 @@ import { SATELLITE_MARKERS, SatelliteMarkerId } from './constants'; import SatelliteButton from './satellite-button'; import Satellite from './satellite-content'; -import tailwindConfig from '@/../tailwind.config'; -const { theme } = resolveConfig(tailwindConfig); - -const getThemeSize = (size: string) => { - if (theme?.screens && size in theme?.screens) { - const screenSize = (theme?.screens?.[size as keyof typeof theme.screens] as string)?.replace( - 'px', - '' - ); - if (isFinite(Number(screenSize))) { - return Number(screenSize); - } - } - return 1; -}; - const Home = () => { const { default: map } = useMap(); @@ -46,6 +34,11 @@ const Home = () => { width: 1, height: 1, }); + const breakpoint = useBreakpoint(); + const isMobile = !breakpoint('sm'); + const isLg = breakpoint('xl'); + + const [paddingTop, setPaddingTop] = useState(0); const spin = useCallback(() => { if (!map) return; @@ -54,22 +47,21 @@ const Home = () => { const nextLng = (currCenter.lng + 0.5) % 360; const nextLat = currCenter.lat + 0.3; const lat = nextLat < -90 ? nextLat + 180 : nextLat > 90 ? nextLat - 180 : nextLat; - map?.easeTo({ bearing: 0, pitch: 0, - zoom: 2, + zoom: isMobile ? DEFAULT_MOBILE_ZOOM : 2, center: { lng: nextLng, lat }, duration: 500, padding: { - left: size.width * 0.45, + left: !isMobile ? size.width * 0.45 : 0, right: 0, - top: 0, - bottom: size.width >= getThemeSize('xl') ? 0 : size.height * 0.5, + top: isMobile ? paddingTop : 0, + bottom: isLg || isMobile ? 0 : size.height * 0.5, }, easing: (n) => n, }); - }, [size, map]); + }, [isLg, isMobile, map, paddingTop, size.height, size.width]); useEffect(() => { if (map) { @@ -89,6 +81,7 @@ const Home = () => { const w = window?.innerWidth || 1; const h = window?.innerHeight || 1; setSize({ width: w, height: h }); + setPaddingTop(h * 1); }; if (typeof window !== 'undefined') { @@ -111,98 +104,145 @@ const Home = () => { visible: { opacity: 1 }, }; + const [firstRender, setFirstRender] = useState(true); + + useEffect(() => { + // Scroll to top on first render + if (typeof window !== 'undefined') { + window.scroll({ top: 0 }); + } + // Prevent pushing to globe before scrolling top on first render + setFirstRender(false); + }, []); + + const containerRef = useRef(null); + + const { scrollYProgress } = useScroll({ + target: containerRef, + axis: 'y', + offset: ['start start', 'end end'], + layoutEffect: false, + }); + + const router = useRouter(); + useMotionValueEvent(scrollYProgress, 'change', (v) => { + if (!isMobile || firstRender) return; + setPaddingTop(size.height * (1 - v)); + + if (v > 0.8) { + router.push('/globe'); + } + }); + return ( -
-
-
-
-
-
-
-
-
-
- Welcome to the + <> +
+
+
+
+
+
+
+
+
+
+ Welcome to the +
+
+ Impact Sphere +
-
- Impact Sphere +
+
+

+ Uncover the stories told by powerful satellites, revealing their crucial role in + addressing global challenges. From monitoring climate change to enhancing + precision agriculture, the GDA program utilises satellite data to accelerate + impact. +

+

+ Dive into these uplifting stories and discover how satellites are shaping a more + sustainable and interconnected future for our planet.{' '} + + Ready to explore GDA stories? + +

+

+ Ready to explore GDA stories? +

-
-
-

- Uncover the stories told by powerful satellites, revealing their crucial role in - addressing global challenges. From monitoring climate change to enhancing - precision agriculture, the GDA program utilises satellite data to accelerate - impact. -

-

- Dive into these uplifting stories and discover how satellites are shaping a more - sustainable and interconnected future for our planet.{' '} - - Ready to explore GDA stories? - -

+
+ map?.stop()} + className="font-bold uppercase tracking-wide" + href="/globe" + > +
+ Explore +
+
-
- map?.stop()} - className="font-bold uppercase tracking-wide" - href="/globe" - > -
- Explore -
- -
-
- -
-
-
- +
+ + + Scroll to explore + +
+ + {/* Desktop orbiting satellites */} + +
+
+
+ + +
-
-
- + +
+ + { + if (!open) { + setSelectedMarker(null); + } + }} + > + + setSelectedMarker(id)} /> + +
- { - if (!open) { - setSelectedMarker(null); - } - }} - > - - setSelectedMarker(id)} /> - - -
+ ); }; diff --git a/client/src/containers/map/index.tsx b/client/src/containers/map/index.tsx index eb728b8..5c8d6ee 100644 --- a/client/src/containers/map/index.tsx +++ b/client/src/containers/map/index.tsx @@ -28,9 +28,6 @@ import { CustomMapProps } from '@/components/map/types'; import SelectedStoriesMarker from './markers/selected-stories-marker'; -const MapLegends = dynamic(() => import('@/containers/map/legend'), { - ssr: false, -}); const LayerManager = dynamic(() => import('@/containers/map/layer-manager'), { ssr: false, }); @@ -52,19 +49,16 @@ export default function MapContainer() { const pathname = usePathname(); - const isGlobePage = pathname.includes('globe'); + const isGlobePage = useMemo(() => pathname.includes('globe'), [pathname]); + const isLandingPage = useMemo(() => pathname.includes('home'), [pathname]); + const isStoriesPage = useMemo(() => pathname.includes('stories'), [pathname]); const tmpBounds: CustomMapProps['bounds'] = useMemo(() => { if (tmpBbox?.bbox) { return { bbox: tmpBbox?.bbox, options: tmpBbox?.options ?? { - padding: { - top: 50, - bottom: 50, - left: 50, - right: 50, - }, + padding: initialViewState?.padding, }, }; } @@ -115,46 +109,41 @@ export default function MapContainer() { center: [longitude, latitude], duration: 1000, animate: true, - padding: { - top: 50, - bottom: 50, - left: 50, - right: 50, - }, + padding: initialViewState?.padding, }); } - }, [map, tmpBbox]); + }, [map, initialViewState, tmpBbox]); return ( -
- - - - {(isGlobePage || pathname.includes('home')) && } - setMarkers([])} /> - {pathname.includes('stories') && } - -
- +
+
+ + + + {(isGlobePage || isLandingPage) && } + setMarkers([])} /> + {isStoriesPage && } +
); diff --git a/client/src/containers/map/legend/index.tsx b/client/src/containers/map/legend/index.tsx index a266f68..06eff77 100644 --- a/client/src/containers/map/legend/index.tsx +++ b/client/src/containers/map/legend/index.tsx @@ -125,41 +125,40 @@ const MapLegends = ({ className = '' }) => { }, [layersData?.data, layersSettings]); return ( -
- - {LEGENDS?.map((legend, index) => ( - { - handleChangeOpacity(legend.id, opacity); - }} - onChangeVisibility={(visibility: boolean) => { - handleChangeVisibility(legend.id, visibility); - }} - onChangeExpand={(expand: boolean) => { - handleChangeExpand(legend.id, expand); - }} - isFetching={isFetching} - isFetched={isFetched} - isError={isError} - sortable={{ - enabled: false, - handle: false, - }} - /> - ))} - -
+ + {LEGENDS?.map((legend, index) => ( + { + handleChangeOpacity(legend.id, opacity); + }} + onChangeVisibility={(visibility: boolean) => { + handleChangeVisibility(legend.id, visibility); + }} + onChangeExpand={(expand: boolean) => { + handleChangeExpand(legend.id, expand); + }} + isFetching={isFetching} + isFetched={isFetched} + isError={isError} + sortable={{ + enabled: false, + handle: false, + }} + /> + ))} + ); }; diff --git a/client/src/containers/map/markers/selected-stories-marker/index.tsx b/client/src/containers/map/markers/selected-stories-marker/index.tsx index 41a705d..3921e25 100644 --- a/client/src/containers/map/markers/selected-stories-marker/index.tsx +++ b/client/src/containers/map/markers/selected-stories-marker/index.tsx @@ -1,7 +1,13 @@ 'use client'; +import { useEffect } from 'react'; + +import { useMap } from 'react-map-gl'; + import { useRouter } from 'next/navigation'; +import { useBreakpoint } from '@/hooks/screen-size'; + import Marker from '@/components/map/layers/marker'; type SelectedStoriesMarkerProps = { @@ -12,6 +18,22 @@ type SelectedStoriesMarkerProps = { const SelectedStoriesMarker = ({ markers, onCloseMarker }: SelectedStoriesMarkerProps) => { const { push } = useRouter(); + const breakpoint = useBreakpoint(); + const isMobile = !breakpoint('sm'); + const { ['default']: map } = useMap(); + + useEffect(() => { + if (isMobile && markers?.length) { + const { coordinates } = markers?.[0]?.geometry || {}; + if (coordinates?.length) { + map?.flyTo({ + center: coordinates as [number, number], + duration: 500, + }); + } + } + }, [markers, map, isMobile]); + if (!markers?.length) return null; const handleClick = (id: string | number) => { @@ -19,7 +41,7 @@ const SelectedStoriesMarker = ({ markers, onCloseMarker }: SelectedStoriesMarker push(`/stories/${id}`); }; - return ; + return ; }; export default SelectedStoriesMarker; diff --git a/client/src/containers/map/markers/story-markers/carousel.tsx b/client/src/containers/map/markers/story-markers/carousel.tsx index 61715d3..ae38591 100644 --- a/client/src/containers/map/markers/story-markers/carousel.tsx +++ b/client/src/containers/map/markers/story-markers/carousel.tsx @@ -19,7 +19,7 @@ type CarouselMediaProps = { isCurrentMedia?: boolean; }; -const CarouselMedia = ({ media, isCurrentMedia }: CarouselMediaProps) => { +export const CarouselMedia = ({ media, isCurrentMedia }: CarouselMediaProps) => { const videoRef = useRef(null); const mediaSrc = getImageSrc(media?.url); diff --git a/client/src/containers/map/markers/story-markers/index.tsx b/client/src/containers/map/markers/story-markers/index.tsx index 42f7d90..d969cb1 100644 --- a/client/src/containers/map/markers/story-markers/index.tsx +++ b/client/src/containers/map/markers/story-markers/index.tsx @@ -9,9 +9,9 @@ import { useSyncStep } from '@/store/stories'; import { useGetStoriesId } from '@/types/generated/story'; import { StoryStepMap } from '@/types/story'; +import Carousel from '@/components/ui/carousel'; import { Dialog, DialogContent } from '@/components/ui/dialog'; -import Carousel from './carousel'; import StoryMarkerMedia from './marker'; type StoryMarker = { @@ -64,8 +64,8 @@ const StoryMarkers = () => { onOpenChange={() => setCurrentMedia(undefined)} open={typeof currentMedia === 'number'} > - - + +

diff --git a/client/src/containers/story/header.tsx b/client/src/containers/story/header.tsx index 72b3638..7467fbd 100644 --- a/client/src/containers/story/header.tsx +++ b/client/src/containers/story/header.tsx @@ -1,8 +1,14 @@ import { useRouter } from 'next/navigation'; import { useSetAtom } from 'jotai'; -import { FacebookIcon, LinkedinIcon, Share2, TwitterIcon, XIcon } from 'lucide-react'; - +import { ArrowLeft, FacebookIcon, LinkedinIcon, Share2, TwitterIcon, XIcon } from 'lucide-react'; +import { + motion, + useMotionValue, + useMotionValueEvent, + useScroll, + useTransform, +} from 'framer-motion'; import env from '@/env.mjs'; import { layersAtom } from '@/store/map'; @@ -10,9 +16,12 @@ import { layersAtom } from '@/store/map'; import { Button } from '@/components/ui/button'; import CategoryIcon from '@/components/ui/category-icon'; import { Dialog, DialogContentHome, DialogTrigger } from '@/components/ui/dialog'; +import { cn } from '@/lib/classnames'; +import { use, useRef, useState } from 'react'; +import { c } from 'nuqs/dist/serializer-5da93b5e'; const headerButtonClassName = - 'rounded-4xl h-auto border border-gray-800 bg-gray-900 px-5 py-2.5 hover:bg-gray-800'; + 'h-8 px-4 py-2 rounded-4xl sm:h-auto border border-gray-800 bg-gray-900 sm:px-5 sm:py-2.5 hover:bg-gray-800'; const shareIcons = [ { @@ -53,21 +62,37 @@ const StoryHeader = ({ categorySlug, title, categoryTitle, storyId }: StoryHeade navigator?.clipboard?.writeText(storyUrl); }; + const mobileRef = useRef(null); + + const { scrollY } = useScroll({ + target: mobileRef, + }); + + const [collapsed, setCollapsed] = useState(false); + + useMotionValueEvent(scrollY, 'change', (latest) => { + if (latest > 375) { + setCollapsed(true); + } else { + setCollapsed(false); + } + }); + return ( -
-
-
- -

- {categoryTitle}: {title} -

-
-
+ <> +
+
+
+ +

+ {categoryTitle}: {title} +

+
- - + + - +
Share story @@ -109,12 +134,109 @@ const StoryHeader = ({ categorySlug, title, categoryTitle, storyId }: StoryHeade
- +
+
+ +
+
+ + +
+ +

+ {categoryTitle}: {title} +

+
+ + + + + + +
+
+ Share story +
+
+
+ Copy and paste link to share +
+
+
+
+ {storyUrl} +
+
+
+ +
+
+
+
+ {shareIcons.map(({ icon: Icon, link }) => ( + + + + ))} +
+
+
+
-
+ ); }; diff --git a/client/src/containers/story/index.tsx b/client/src/containers/story/index.tsx index 45c8097..8b879b6 100644 --- a/client/src/containers/story/index.tsx +++ b/client/src/containers/story/index.tsx @@ -62,7 +62,7 @@ const Story = () => { }, [story, setTmpBbox, setLayers, steps, step]); return ( -
+
{ /> -
+
{steps?.map((s, index) => ( ))} diff --git a/client/src/containers/story/steps/controller/controller-item.tsx b/client/src/containers/story/steps/controller/controller-item.tsx index a11b279..a43f1ca 100644 --- a/client/src/containers/story/steps/controller/controller-item.tsx +++ b/client/src/containers/story/steps/controller/controller-item.tsx @@ -40,7 +40,7 @@ export const ScrollItemController = ({ title, newStep }: ScrollItemControllerPro variant="icon" className={cn( 'outline-secondary h-4 w-4 rounded-full border-[1.5px] border-gray-800 bg-gray-900 transition-all duration-200 hover:outline', - newStep === currStep ? 'border-secondary borde' : 'border-gray-700' + newStep === currStep ? 'border-secondary border' : 'border-gray-700' )} onClick={handleSCrollToItem} size="icon" diff --git a/client/src/containers/story/steps/controller/scroll-item.tsx b/client/src/containers/story/steps/controller/scroll-item.tsx index 6848723..a545ba7 100644 --- a/client/src/containers/story/steps/controller/scroll-item.tsx +++ b/client/src/containers/story/steps/controller/scroll-item.tsx @@ -2,8 +2,9 @@ import { PropsWithChildren, useRef } from 'react'; import { useScroll } from 'framer-motion'; +import { useResizeObserverRef } from 'rooks'; -import { useAddScrollItem } from '@/lib/scroll'; +import { useAddScrollItem, useUpdateScrollItem } from '@/lib/scroll'; interface ScrollItemProps extends PropsWithChildren { step: number; @@ -17,6 +18,20 @@ export const ScrollItem = ({ children, step }: ScrollItemProps) => { offset: ['0 1', '1 0'], }); + // Update section height on resize + const updateScrollItem = useUpdateScrollItem(); + const [resizeRef] = useResizeObserverRef(() => { + updateScrollItem({ + ref, + key: `scroll-${step}`, + data: { + step, + }, + ...scrollMotionValue, + }); + }); + + // Add section to scroll context useAddScrollItem({ ref, key: `scroll-${step}`, @@ -28,7 +43,7 @@ export const ScrollItem = ({ children, step }: ScrollItemProps) => { return (
- {children} +
{children}
); }; diff --git a/client/src/containers/story/steps/index.tsx b/client/src/containers/story/steps/index.tsx index aea6250..160dad8 100644 --- a/client/src/containers/story/steps/index.tsx +++ b/client/src/containers/story/steps/index.tsx @@ -69,7 +69,7 @@ const Step = ({ story }: StepProps) => {
{type === 'map-step' && ( diff --git a/client/src/containers/story/steps/layouts/components/map-content.tsx b/client/src/containers/story/steps/layouts/components/map-content.tsx index 379fd45..ebd346f 100644 --- a/client/src/containers/story/steps/layouts/components/map-content.tsx +++ b/client/src/containers/story/steps/layouts/components/map-content.tsx @@ -17,12 +17,12 @@ const MapContent = ({ showContent, title, titlePlaceholder, children }: MapConte - -

+ +

{title ? ( title ) : ( @@ -34,7 +34,9 @@ const MapContent = ({ showContent, title, titlePlaceholder, children }: MapConte -
+
{children}
diff --git a/client/src/containers/story/steps/layouts/map-step.tsx b/client/src/containers/story/steps/layouts/map-step.tsx index 47e55ad..2c5a5e1 100644 --- a/client/src/containers/story/steps/layouts/map-step.tsx +++ b/client/src/containers/story/steps/layouts/map-step.tsx @@ -1,5 +1,7 @@ 'use client'; +import dynamic from 'next/dynamic'; + import { InfoIcon } from 'lucide-react'; import { cn } from '@/lib/classnames'; @@ -21,6 +23,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import MapContent from './components/map-content'; +const MapLegends = dynamic(() => import('@/containers/map/legend'), { + ssr: false, +}); + export type StorySummary = { title: string; content?: (StoryIfisDataItem | StoryTagsDataItem)[]; @@ -41,11 +47,15 @@ const MapStepLayout = ({ step, showContent, storySummary }: MapStepLayoutProps)
-
+
+ +
+
{!!card && ( )} {!!storySummary?.length && ( -
+
{storySummary?.map((item) => (
{currentStep === 1 && ( -
+
Scroll down to explore the story
)} diff --git a/client/src/containers/story/steps/layouts/outro-step.tsx b/client/src/containers/story/steps/layouts/outro-step.tsx index b49b106..af06f0f 100644 --- a/client/src/containers/story/steps/layouts/outro-step.tsx +++ b/client/src/containers/story/steps/layouts/outro-step.tsx @@ -5,15 +5,15 @@ import { useEffect, useRef, useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import { useScroll, motion, useTransform } from 'framer-motion'; -import { useSetAtom } from 'jotai'; +import { useScroll, motion, useTransform, useMotionValueEvent } from 'framer-motion'; +import { cn } from '@/lib/classnames'; import { getImageSrc } from '@/lib/image-src'; -import { isFlyingBackAtom } from '@/store/map'; - import { StepLayoutOutroStepComponent } from '@/types/generated/strapi.schemas'; +import { useBreakpoint } from '@/hooks/screen-size'; + import ScrollExplanation from '@/components/ui/scroll-explanation'; type Disclaimer = { @@ -35,8 +35,6 @@ type MediaStepLayoutProps = { const OutroStepLayout = ({ step, showContent, disclaimer }: MediaStepLayoutProps) => { const { push } = useRouter(); - const setIsFlyingBack = useSetAtom(isFlyingBackAtom); - const { content, title } = step as StepLayoutOutroStepComponent; const containerRef = useRef(null); @@ -48,23 +46,22 @@ const OutroStepLayout = ({ step, showContent, disclaimer }: MediaStepLayoutProps const [show, setShow] = useState(false); - useTransform(scrollYProgress, (v) => { - if (!show && showContent && v > 0.2) { - setShow(true); - } - }); + const breakpoint = useBreakpoint(); + const isMobile = !breakpoint('sm'); useEffect(() => { if (!showContent) setShow(false); }, [showContent]); - useTransform(scrollYProgress, (v) => { + useMotionValueEvent(scrollYProgress, 'change', (v) => { + if (!show && showContent && v > 0.2) { + if (!isMobile && v > 0.2) setShow(true); + if (isMobile && v > 0.1) setShow(true); + } + if (show && v < 0.2) setShow(false); + if (v > 0.6) { - setIsFlyingBack(true); - setTimeout(() => { - setIsFlyingBack(false); - }, 3000); - push('/'); + push('/globe'); } }); @@ -86,120 +83,132 @@ const OutroStepLayout = ({ step, showContent, disclaimer }: MediaStepLayoutProps const categoryDisclaimer = disclaimer as Disclaimer[]; return ( -
- {showContent && show && ( - - -
-
-
-
- - {isVideo && ( - - )} - {isImage && ( - story conclusion image - )} - - - -
-

- {title} -

-

{content}

-
-
-
-
+
+ + +
+
-
- Continue scrolling to explore more stories +
+
+ + {isVideo && ( + + )} + {isImage && ( + story conclusion image + )} + + + +
+

+ {title} +

+

{content}

+
+
+
- {showContent && show && categoryDisclaimer?.length && ( -
-
    - {categoryDisclaimer.map((item) => ( -
  • -

    {item.title}

    -
    - {item.partners?.map((partner) => { - const src = getImageSrc(partner.logo?.data?.attributes?.url); - - const url = partner.url; - return url ? ( - - - - ) : ( -
    - -
    - ); - })} -
    -
  • - ))} -
-
+
+ Continue scrolling to explore more stories +
+ +
- )} + > +
    + {categoryDisclaimer.map((item) => ( +
  • +

    {item.title}

    +
    + {item.partners?.map((partner) => { + const src = getImageSrc(partner.logo?.data?.attributes?.url); + + const url = partner.url; + return url ? ( + + + + ) : ( +
    + +
    + ); + })} +
    +
  • + ))} +
+
+
); }; diff --git a/client/src/hooks/screen-size/index.ts b/client/src/hooks/screen-size/index.ts new file mode 100644 index 0000000..0323129 --- /dev/null +++ b/client/src/hooks/screen-size/index.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; + +import resolveConfig from 'tailwindcss/resolveConfig'; + +import tailwindConfig from '@/../tailwind.config'; +const { theme } = resolveConfig(tailwindConfig); + +const getThemeSize = (size: string) => { + if (theme?.screens && size in theme?.screens) { + const screenSize = (theme?.screens?.[size as keyof typeof theme.screens] as string)?.replace( + 'px', + '' + ); + if (isFinite(Number(screenSize))) { + return Number(screenSize); + } + } + return 1; +}; + +export const useBreakpoint = () => { + const [width, setWidth] = useState(0); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + setWidth(window.innerWidth); + + const handleResize = () => { + setWidth(window.innerWidth); + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return (screenSize: string) => { + return width >= getThemeSize(screenSize); + }; +}; diff --git a/client/src/lib/scroll/index.tsx b/client/src/lib/scroll/index.tsx index f1a0479..9ef4a2a 100644 --- a/client/src/lib/scroll/index.tsx +++ b/client/src/lib/scroll/index.tsx @@ -28,6 +28,7 @@ interface ScrollContext { scrollItems: ScrollItem[]; addScrollItem: (data: ScrollItem) => void; scrollToItem: (item: number | string) => void; + updateScrollItem: (data: ScrollItem) => void; } const Context = createContext({ @@ -36,6 +37,8 @@ const Context = createContext({ addScrollItem: () => {}, // eslint-disable-next-line @typescript-eslint/no-empty-function scrollToItem: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + updateScrollItem: () => {}, }); const useIsomorphicLayoutEffect = @@ -61,6 +64,21 @@ export const ScrollProvider = ({ children }: PropsWithChildren) => { [scrollItems, setScrollItems] ); + const updateScrollItem = useCallback( + (data) => { + return setScrollItems((prev) => { + return prev.map((item) => { + if (item.key === data.key) { + return data; + } + + return item; + }); + }); + }, + [setScrollItems] + ); + const scrollToItem = useCallback( (item) => { const scrollItem = scrollItems.find((i) => i.key === `scroll-${item}`); @@ -97,8 +115,9 @@ export const ScrollProvider = ({ children }: PropsWithChildren) => { scrollItems, addScrollItem, scrollToItem, + updateScrollItem, }), - [scrollItems, addScrollItem, scrollToItem] + [scrollItems, addScrollItem, scrollToItem, updateScrollItem] ); useMotionValueEvent(scrollY, 'change', (v) => { @@ -108,7 +127,6 @@ export const ScrollProvider = ({ children }: PropsWithChildren) => { const h = acc.height + currentH; const accH = Math.max(acc.height - window.innerHeight * 0.5, 0); - // console.log({ currentH, accHeight: acc.height, v }); if (v < accH) { return { key: acc.key, @@ -171,6 +189,14 @@ export function useAddScrollItem(data: ScrollItem) { return null; } +export function useUpdateScrollItem() { + const { updateScrollItem } = useContext(Context); + + return (data: ScrollItem) => { + updateScrollItem(data); + }; +} + export const useScrollToItem = () => { const { scrollToItem } = useContext(Context); diff --git a/client/src/store/map.ts b/client/src/store/map.ts index ac212cd..b1fda39 100644 --- a/client/src/store/map.ts +++ b/client/src/store/map.ts @@ -48,8 +48,6 @@ export const popupAtom = atom(null); export const markerAtom = atom(null); -export const isFlyingBackAtom = atom(false); - export const DEFAULT_SETTINGS = { expand: true, }; diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css index b0664a8..b9c50a5 100644 --- a/client/src/styles/globals.css +++ b/client/src/styles/globals.css @@ -75,6 +75,6 @@ @apply border-border; } body { - @apply bg-background text-foreground font-open-sans antialiased; + @apply bg-map-background text-foreground font-open-sans antialiased; } } diff --git a/client/src/styles/mapbox.css b/client/src/styles/mapbox.css index 9d53d73..694aad9 100644 --- a/client/src/styles/mapbox.css +++ b/client/src/styles/mapbox.css @@ -322,7 +322,7 @@ a.mapboxgl-ctrl-logo.mapboxgl-compact { .mapboxgl-ctrl-attrib.mapboxgl-compact { min-height: 24px; padding: 0px 24px 0px 0; - margin: 10px 24px 24px 10px; + margin: 10px 8px 4px 10px; position: relative; background-color: #fff; border-radius: 12px; diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 7d08e47..7ec0750 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -83,11 +83,16 @@ module.exports = { '0%': { opacity: 0, transform: 'translateY(0)' }, '100%': { opacity: 1, transform: 'translateY(20px)' }, }, + 'fade-up': { + '0%': { opacity: 1, transform: 'translateY(0)' }, + '100%': { opacity: 0, transform: 'translateY(-20px)' }, + }, }, animation: { 'fade-down': 'fade-down 2s ease-in-out infinite', 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', + 'fade-up': 'fade-up 2s ease-in-out infinite', }, fontFamily: { 'open-sans': ['var(--font-open-sans)'], diff --git a/yarn.lock b/yarn.lock index 879d8e1..e514b4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2855,6 +2855,8 @@ __metadata: d3-array: 3.2.4 date-fns: ^3.3.1 deck.gl: 8.9.19 + embla-carousel: ^8.3.0 + embla-carousel-react: ^8.3.0 eslint: 8.42.0 eslint-config-next: 13.4.5 eslint-config-prettier: ^8.8.0 @@ -11490,6 +11492,34 @@ __metadata: languageName: node linkType: hard +"embla-carousel-react@npm:^8.3.0": + version: 8.3.0 + resolution: "embla-carousel-react@npm:8.3.0" + dependencies: + embla-carousel: 8.3.0 + embla-carousel-reactive-utils: 8.3.0 + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 + checksum: 414bf10ea5b983fdf2f64aeb7806ebaf69b2790617c8ff13b13e7bb6cc4671f243544a786fc6be33dde18965513402e4c4d78474ca928f4031af7db9e276be26 + languageName: node + linkType: hard + +"embla-carousel-reactive-utils@npm:8.3.0": + version: 8.3.0 + resolution: "embla-carousel-reactive-utils@npm:8.3.0" + peerDependencies: + embla-carousel: 8.3.0 + checksum: 687eb69c5db4bf1c9fc09090ea4441e60ab94dd04de7b8e69139eff26fed3d9ba62e6804cc1c44616838c14924fe1b07e31864222a6106365c7129fd95d7d741 + languageName: node + linkType: hard + +"embla-carousel@npm:8.3.0, embla-carousel@npm:^8.3.0": + version: 8.3.0 + resolution: "embla-carousel@npm:8.3.0" + checksum: c2d3c89c93e105133e6931dfdba5899f44f01b32ea0fa331999030150819a2a51c4699df49b69540bc935b63def219f7484f6ca363becaacaf7609e863e39b72 + languageName: node + linkType: hard + "emittery@npm:^0.12.1": version: 0.12.1 resolution: "emittery@npm:0.12.1"