diff --git a/web/app/components/base/carousel/index.tsx b/web/app/components/base/carousel/index.tsx index 5c31776ae9..c5e28ca04c 100644 --- a/web/app/components/base/carousel/index.tsx +++ b/web/app/components/base/carousel/index.tsx @@ -14,6 +14,7 @@ type CarouselProps = { opts?: CarouselOptions plugins?: CarouselPlugin orientation?: 'horizontal' | 'vertical' + overlay?: React.ReactNode } type CarouselContextValue = { @@ -49,7 +50,7 @@ type TCarousel = { > const Carousel: TCarousel = React.forwardRef( - ({ orientation = 'horizontal', opts, plugins, className, children, ...props }, ref) => { + ({ orientation = 'horizontal', opts, plugins, overlay, className, children, ...props }, ref) => { const [carouselRef, api] = useEmblaCarousel( { ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' }, plugins, @@ -115,14 +116,19 @@ const Carousel: TCarousel = React.forwardRef( }} >
- {children} + {overlay} +
+ {children} +
) diff --git a/web/app/components/plugins/marketplace/list/carousel.tsx b/web/app/components/plugins/marketplace/list/carousel.tsx index 42846e7793..7f642d09fb 100644 --- a/web/app/components/plugins/marketplace/list/carousel.tsx +++ b/web/app/components/plugins/marketplace/list/carousel.tsx @@ -2,13 +2,13 @@ import type { RemixiconComponentType } from '@remixicon/react' import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { useMemo } from 'react' +import { Carousel as BaseCarousel, useCarousel } from '@/app/components/base/carousel' import { cn } from '@/utils/classnames' type CarouselProps = { children: React.ReactNode className?: string - itemWidth?: number gap?: number showNavigation?: boolean showPagination?: boolean @@ -16,22 +16,6 @@ type CarouselProps = { autoPlayInterval?: number } -type ScrollState = { - canScrollLeft: boolean - canScrollRight: boolean - currentPage: number - totalPages: number -} - -const SCROLL_OVERLAP_RATIO = 0.5 - -const defaultScrollState: ScrollState = { - canScrollLeft: false, - canScrollRight: false, - currentPage: 0, - totalPages: 0, -} - type NavButtonProps = { direction: 'left' | 'right' disabled: boolean @@ -55,200 +39,89 @@ const NavButton = ({ direction, disabled, onClick, Icon }: NavButtonProps) => ( ) +type CarouselControlsProps = { + showPagination: boolean +} + +const CarouselControls = ({ showPagination }: CarouselControlsProps) => { + const { api, selectedIndex, scrollPrev, scrollNext } = useCarousel() + const scrollSnaps = api?.scrollSnapList() ?? [] + const totalPages = scrollSnaps.length + + if (totalPages <= 1) + return null + + return ( +
+ {showPagination && ( +
+ {scrollSnaps.map((snap, index) => ( +
+ )} + +
+ + +
+
+ ) +} + const Carousel = ({ children, className, - itemWidth = 280, gap = 12, showNavigation = true, showPagination = true, autoPlay = false, autoPlayInterval = 5000, }: CarouselProps) => { - const containerRef = useRef(null) - const scrollStateRef = useRef(defaultScrollState) - const [isHovered, setIsHovered] = useState(false) + const plugins = useMemo(() => { + if (!autoPlay) + return [] - const calculateScrollState = useCallback((container: HTMLDivElement): ScrollState => { - const { scrollLeft, scrollWidth, clientWidth } = container - const canScrollLeft = scrollLeft > 0 - const canScrollRight = scrollLeft < scrollWidth - clientWidth - 1 - - // Calculate total pages based on actual scroll range - const maxScrollLeft = scrollWidth - clientWidth - const itemsPerPage = Math.floor(clientWidth / (itemWidth + gap)) - const totalItems = container.children.length - const pages = Math.max(1, Math.ceil(totalItems / itemsPerPage)) - - // Calculate current page based on scroll position ratio - let currentPage = 0 - if (maxScrollLeft > 0) { - const scrollRatio = scrollLeft / maxScrollLeft - currentPage = Math.round(scrollRatio * (pages - 1)) - } - - return { - canScrollLeft, - canScrollRight, - totalPages: pages, - currentPage: Math.min(Math.max(0, currentPage), pages - 1), - } - }, [itemWidth, gap]) - - const subscribe = useCallback((onStoreChange: () => void) => { - const container = containerRef.current - if (!container) - return () => { } - - const handleChange = () => { - scrollStateRef.current = calculateScrollState(container) - onStoreChange() - } - - // Initial calculation - handleChange() - - const resizeObserver = new ResizeObserver(handleChange) - resizeObserver.observe(container) - container.addEventListener('scroll', handleChange) - - return () => { - resizeObserver.disconnect() - container.removeEventListener('scroll', handleChange) - } - }, [calculateScrollState]) - - const getSnapshot = useCallback(() => scrollStateRef.current, []) - - const scrollState = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) - - // Re-subscribe when children change - useEffect(() => { - const container = containerRef.current - if (container) - scrollStateRef.current = calculateScrollState(container) - }, [children, calculateScrollState]) - - const scrollToPage = useCallback((pageIndex: number, instant = false) => { - const container = containerRef.current - if (!container) - return - - const itemsPerPage = Math.floor(container.clientWidth / (itemWidth + gap)) - const scrollLeft = pageIndex * itemsPerPage * (itemWidth + gap) - - container.scrollTo({ - left: scrollLeft, - behavior: instant ? 'instant' : 'smooth', - }) - }, [itemWidth, gap]) - - const scroll = useCallback((direction: 'left' | 'right') => { - const container = containerRef.current - if (!container) - return - - // Handle looping - if (direction === 'left' && !scrollState.canScrollLeft) { - // At first page, loop to last page - scrollToPage(scrollState.totalPages - 1, true) - return - } - if (direction === 'right' && !scrollState.canScrollRight) { - // At last page, loop to first page - scrollToPage(0, true) - return - } - - const scrollAmount = container.clientWidth - (itemWidth * SCROLL_OVERLAP_RATIO) - const newScrollLeft = direction === 'left' - ? container.scrollLeft - scrollAmount - : container.scrollLeft + scrollAmount - - container.scrollTo({ - left: newScrollLeft, - behavior: 'smooth', - }) - }, [itemWidth, scrollState.canScrollLeft, scrollState.canScrollRight, scrollState.totalPages, scrollToPage]) - - // Auto-play functionality - useEffect(() => { - if (!autoPlay || isHovered || scrollState.totalPages <= 1) - return - - const interval = setInterval(() => { - if (scrollState.canScrollRight) { - scrollToPage(scrollState.currentPage + 1) - } - else { - // Loop back to first page instantly (no animation) - scrollToPage(0, true) - } - }, autoPlayInterval) - - return () => clearInterval(interval) - }, [autoPlay, autoPlayInterval, isHovered, scrollState.totalPages, scrollState.canScrollRight, scrollState.currentPage, scrollToPage]) - - const handleMouseEnter = useCallback(() => setIsHovered(true), []) - const handleMouseLeave = useCallback(() => setIsHovered(false), []) + return [ + BaseCarousel.Plugin.Autoplay({ + delay: autoPlayInterval, + stopOnInteraction: false, + stopOnMouseEnter: true, + }), + ] + }, [autoPlay, autoPlayInterval]) return ( -
: null} > - {/* Navigation arrows */} - {showNavigation && ( -
- {/* Pagination dots */} - {showPagination && scrollState.totalPages > 1 && ( -
- {Array.from({ length: scrollState.totalPages }).map((_, index) => ( -
- )} - -
- scroll('left')} - Icon={RiArrowLeftSLine} - /> - scroll('right')} - Icon={RiArrowRightSLine} - /> -
-
- )} - - {/* Scrollable container */} -
+ {children} -
-
+ + ) } diff --git a/web/app/components/plugins/marketplace/list/collection-constants.ts b/web/app/components/plugins/marketplace/list/collection-constants.ts index e3a0b41d56..ff74d82d2d 100644 --- a/web/app/components/plugins/marketplace/list/collection-constants.ts +++ b/web/app/components/plugins/marketplace/list/collection-constants.ts @@ -2,10 +2,22 @@ export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 export const GRID_DISPLAY_LIMIT = 4 -export const CAROUSEL_COLUMN_CLASS = 'flex w-[calc((100%-0px)/1)] shrink-0 flex-col gap-3 sm:w-[calc((100%-12px)/2)] lg:w-[calc((100%-24px)/3)] xl:w-[calc((100%-36px)/4)]' +export const CAROUSEL_PAGE_CLASS = 'w-full shrink-0' -/** Max visible columns at the widest (xl) breakpoint; used to decide 1-row vs 2-row carousel layout. */ -export const CAROUSEL_MAX_VISIBLE_COLUMNS = 4 +export const CAROUSEL_PAGE_GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' + +export const CAROUSEL_PAGE_SIZE = { + base: 2, + sm: 4, + lg: 6, + xl: 8, +} as const + +export const CAROUSEL_BREAKPOINTS = { + sm: 640, + lg: 1024, + xl: 1280, +} as const /** Collection name key that triggers carousel display (plugins: partners, templates: featured) */ export const CAROUSEL_COLLECTION_NAMES = { diff --git a/web/app/components/plugins/marketplace/list/collection-list.tsx b/web/app/components/plugins/marketplace/list/collection-list.tsx index 8f5ad7b7f3..0f8fde70b7 100644 --- a/web/app/components/plugins/marketplace/list/collection-list.tsx +++ b/web/app/components/plugins/marketplace/list/collection-list.tsx @@ -6,13 +6,34 @@ import type { BaseCollection } from './collection-constants' import type { Locale } from '@/i18n-config/language' import { useLocale, useTranslation } from '#i18n' import { RiArrowRightSLine } from '@remixicon/react' +import { useEffect, useMemo, useState } from 'react' import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' import { useMarketplaceMoreClick } from '../atoms' import Empty from '../empty' -import { buildCarouselColumns, getItemKeyByField } from '../utils' +import { buildCarouselPages, getItemKeyByField } from '../utils' import Carousel from './carousel' -import { CAROUSEL_COLUMN_CLASS, CAROUSEL_MAX_VISIBLE_COLUMNS, GRID_CLASS, GRID_DISPLAY_LIMIT } from './collection-constants' +import { + CAROUSEL_BREAKPOINTS, + CAROUSEL_PAGE_CLASS, + CAROUSEL_PAGE_GRID_CLASS, + CAROUSEL_PAGE_SIZE, + GRID_CLASS, + GRID_DISPLAY_LIMIT, +} from './collection-constants' + +const getViewportWidth = () => typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth + +const getCarouselItemsPerPage = (viewportWidth: number) => { + if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) + return CAROUSEL_PAGE_SIZE.xl + if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) + return CAROUSEL_PAGE_SIZE.lg + if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) + return CAROUSEL_PAGE_SIZE.sm + + return CAROUSEL_PAGE_SIZE.base +} type ViewMoreButtonProps = { searchParams?: SearchParamsFromCollection @@ -81,25 +102,38 @@ export function CarouselCollection({ renderCard, cardContainerClassName, }: CarouselCollectionProps) { - const columns = buildCarouselColumns(items, CAROUSEL_MAX_VISIBLE_COLUMNS) + const [viewportWidth, setViewportWidth] = useState(getViewportWidth) + + useEffect(() => { + const handleResize = () => setViewportWidth(window.innerWidth) + + window.addEventListener('resize', handleResize) + + return () => window.removeEventListener('resize', handleResize) + }, []) + + const itemsPerPage = useMemo(() => getCarouselItemsPerPage(viewportWidth), [viewportWidth]) + const pages = useMemo(() => buildCarouselPages(items, itemsPerPage), [items, itemsPerPage]) + const hasMultiplePages = pages.length > 1 return ( 8} - showPagination={items.length > 8} - autoPlay={items.length > 8} + showNavigation={hasMultiplePages} + showPagination={hasMultiplePages} + autoPlay={hasMultiplePages} autoPlayInterval={5000} > - {columns.map((columnItems, idx) => ( + {pages.map((pageItems, idx) => (
- {columnItems.map(item => ( -
{renderCard(item)}
- ))} +
+ {pageItems.map(item => ( +
{renderCard(item)}
+ ))} +
))}
diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index fb1741be58..dbf0d90d76 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -32,24 +32,12 @@ export function getItemKeyByField(item: T, field: keyof T): string { return String((item as Record)[field as string]) } -/** - * Group a flat array into columns for a carousel grid layout. - * When the item count exceeds `maxVisibleColumns`, items are arranged in - * a two-row, column-first order with the first row always fully filled. - */ -export function buildCarouselColumns(items: T[], maxVisibleColumns: number): T[][] { - const useDoubleRow = items.length > maxVisibleColumns - const numColumns = useDoubleRow - ? Math.max(maxVisibleColumns, Math.ceil(items.length / 2)) - : items.length - const columns: T[][] = [] - for (let i = 0; i < numColumns; i++) { - const column: T[] = [items[i]] - if (useDoubleRow && i + numColumns < items.length) - column.push(items[i + numColumns]) - columns.push(column) - } - return columns +/** Group a flat array into pages for a carousel layout. */ +export function buildCarouselPages(items: T[], itemsPerPage: number): T[][] { + const pages: T[][] = [] + for (let i = 0; i < items.length; i += itemsPerPage) + pages.push(items.slice(i, i + itemsPerPage)) + return pages } export const getPluginIconInMarketplace = (plugin: Plugin) => {