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) => {