diff --git a/assets/icons/Pause_Icon.svg b/assets/icons/Pause_Icon.svg new file mode 100644 index 0000000..55d57ea --- /dev/null +++ b/assets/icons/Pause_Icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/Start_Icon.svg b/assets/icons/Start_Icon.svg new file mode 100644 index 0000000..43723a5 --- /dev/null +++ b/assets/icons/Start_Icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/globals.css b/src/app/globals.css index 33677b5..4ec57cf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -18,6 +18,8 @@ --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + /* Latest Updates (Gallery) desktop breakpoint: >=1167px */ + --breakpoint-gallery-lg: 72.9375rem; /* 1167px */ } /* @media (prefers-color-scheme: dark) { diff --git a/src/components/LandingPage/Gallery/UpdateCard.tsx b/src/components/LandingPage/Gallery/UpdateCard.tsx index 5989b0d..a952b7a 100644 --- a/src/components/LandingPage/Gallery/UpdateCard.tsx +++ b/src/components/LandingPage/Gallery/UpdateCard.tsx @@ -1,6 +1,5 @@ import React from "react"; import Image from "next/image"; -import externalLinkIcon from "../../../../assets/icons/external_link_icon.svg"; interface UpdateCardProps { image: string; @@ -16,49 +15,62 @@ const UpdateCard: React.FC = ({ link, }) => { return ( -
-
-
+
+
+
{title}
-
-

+

+

{title}

-
- +
+

{description.length > 100 ? `${description.slice(0, 100)}...` : description} - - - - Read More - -

- external link -
- +

+ + Read More > +
+ {/* Tablet/mobile only: hover overlay per Figma 2174-9260 — dark overlay, centered text, teal Read More. Hidden overlay uses pointer-events-none + invisible so the link is not focusable; revealed on hover or keyboard focus (focus-within). */} +
+

+ {description.length > 100 + ? `${description.slice(0, 100)}...` + : description} +

+ + Read More > + +
); diff --git a/src/components/LandingPage/Gallery/index.tsx b/src/components/LandingPage/Gallery/index.tsx index dc5b647..d8bae3d 100644 --- a/src/components/LandingPage/Gallery/index.tsx +++ b/src/components/LandingPage/Gallery/index.tsx @@ -1,6 +1,11 @@ "use client"; -import React from "react"; +import React, { useRef, useState, useCallback, useEffect } from "react"; +import Image from "next/image"; import UpdateCard from "./UpdateCard"; +import startIcon from '../../../../assets/icons/Start_Icon.svg' +import pauseIcon from '../../../../assets/icons/Pause_Icon.svg' + +const AUTO_ROTATE_INTERVAL_MS = 5000; export interface GalleryUpdate { image: string; @@ -19,27 +24,181 @@ export interface GalleryConfig { } interface GalleryProps { - data: { gallery: GalleryConfig}; + data: { gallery: GalleryConfig }; +} + +const CARD_WIDTH_MOBILE = 214; +const CARD_GAP_MOBILE = 28; +const SLIDE_STEP = CARD_WIDTH_MOBILE + CARD_GAP_MOBILE; // 242 +const MOBILE_VIEWPORT_OFFSET = -SLIDE_STEP; // -242: show indices 1,2,3 + +/** Build initial mobile list [c, a, b, c, a, b] from updates [a, b, c] so visible indices 1,2,3 show a,b,c */ +function buildMobileRotatingList(updates: GalleryUpdate[] | undefined): GalleryUpdate[] { + if (!updates || updates.length < 3) return []; + const [a, b, c] = updates; + return [c, a, b, c, a, b]; } +const MOBILE_MEDIA_QUERY = "(max-width: 767px)"; + const Gallery: React.FC = ({ data }) => { const config = data?.gallery; - + const [rotatingList, setRotatingList] = useState([]); + const [isPaused, setIsPaused] = useState(false); + const [slideOffset, setSlideOffset] = useState(MOBILE_VIEWPORT_OFFSET); + const [slideTransitionEnabled, setSlideTransitionEnabled] = useState(true); + const [isMobileViewport, setIsMobileViewport] = useState(false); + const slideDirectionRef = useRef<"next" | "prev" | null>(null); + + useEffect(() => { + const mql = window.matchMedia(MOBILE_MEDIA_QUERY); + const update = () => setIsMobileViewport(mql.matches); + update(); + mql.addEventListener("change", update); + return () => mql.removeEventListener("change", update); + }, []); + + useEffect(() => { + if (config?.updates && config.updates.length >= 3) { + setRotatingList(buildMobileRotatingList(config.updates)); + } + }, [config?.updates]); + + const handleSlideTransitionEnd = useCallback(() => { + const dir = slideDirectionRef.current; + if (dir === "next") { + setRotatingList((prev) => + prev.length === 6 ? [...prev.slice(1), prev[0]] : prev + ); + } else if (dir === "prev") { + setRotatingList((prev) => + prev.length === 6 ? [prev[5], ...prev.slice(0, 5)] : prev + ); + } + slideDirectionRef.current = null; + setSlideTransitionEnabled(false); + setSlideOffset(MOBILE_VIEWPORT_OFFSET); + requestAnimationFrame(() => { + requestAnimationFrame(() => setSlideTransitionEnabled(true)); + }); + }, []); + + const goToNext = useCallback(() => { + if (rotatingList.length !== 6 || slideDirectionRef.current) return; + slideDirectionRef.current = "next"; + setSlideOffset(-2 * SLIDE_STEP); // show indices 2,3,4 + }, [rotatingList.length]); + + const goToPrev = useCallback(() => { + if (rotatingList.length !== 6 || slideDirectionRef.current) return; + slideDirectionRef.current = "prev"; + setSlideOffset(0); // show indices 0,1,2 + }, [rotatingList.length]); + + useEffect(() => { + if (!isMobileViewport || rotatingList.length !== 6 || isPaused) return; + const id = setInterval(goToNext, AUTO_ROTATE_INTERVAL_MS); + return () => clearInterval(id); + }, [isMobileViewport, rotatingList.length, isPaused, goToNext]); + if (!config) return null; - + return ( -
-

- {config.title} -

-
-
-
+
+
+

+ {config.title} +

+
+
+ {/* Mobile: 6-item sliding track; viewport shows 3 cards; smooth slide then reset for infinite */} +
+
+
+ {rotatingList.map((update: GalleryUpdate, idx: number) => ( +
+ +
+ ))} +
+
+ {/* Pagination: Left | Pause | Right — infinite, no disabled */} +
+ + + +
+
+ + {/* Tablet + Desktop: row of cards; desktop only title and cards left-aligned */} +
{config.updates.map((update: GalleryUpdate, idx: number) => ( -
+
= ({ data }) => {
))}
- {/* Button below the cards */} -
+
);