diff --git a/web/app/components/plugins/card/base/org-info.tsx b/web/app/components/plugins/card/base/org-info.tsx index 70ad2ffe8b..004ef0f37e 100644 --- a/web/app/components/plugins/card/base/org-info.tsx +++ b/web/app/components/plugins/card/base/org-info.tsx @@ -1,3 +1,4 @@ +import Link from 'next/link' import { cn } from '@/utils/classnames' import DownloadCount from './download-count' @@ -22,8 +23,16 @@ const OrgInfo = ({
{orgName && ( - by - {orgName} + by + e.stopPropagation()} + > + {orgName} + )} · diff --git a/web/app/components/plugins/marketplace/description/hero-illustration.tsx b/web/app/components/plugins/marketplace/description/hero-illustration.tsx deleted file mode 100644 index 8707c3b772..0000000000 --- a/web/app/components/plugins/marketplace/description/hero-illustration.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client' - -// todo: update the illustration -const HeroIllustration = () => { - return ( - - {/* Large circle - top right */} - - {/* Medium circle - middle */} - - {/* Small circle - bottom */} - - {/* Decorative dots */} - - - - - {/* Abstract shapes */} - - - {/* Gradient definitions */} - - - - - - - - - - - - - - - - ) -} - -export default HeroIllustration diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx index 107f99448b..01e869b63d 100644 --- a/web/app/components/plugins/marketplace/description/index.tsx +++ b/web/app/components/plugins/marketplace/description/index.tsx @@ -1,36 +1,164 @@ 'use client' +import type { MotionValue } from 'motion/react' import { useTranslation } from '#i18n' +import { motion, useMotionValue, useSpring, useTransform } from 'motion/react' +import { useEffect, useLayoutEffect, useRef } from 'react' +import marketPlaceBg from '@/public/marketplace/hero-bg.jpg' +import marketplaceGradientNoise from '@/public/marketplace/hero-gradient-noise.svg' import { cn } from '@/utils/classnames' import PluginTypeSwitch from '../plugin-type-switch' -import HeroIllustration from './hero-illustration' type DescriptionProps = { className?: string + scrollContainerId?: string } -export const Description = ({ className }: DescriptionProps) => { +// Constants for collapse animation +const MAX_SCROLL = 120 // pixels to fully collapse +const EXPANDED_PADDING_TOP = 32 // pt-8 +const COLLAPSED_PADDING_TOP = 12 // pt-3 +const EXPANDED_PADDING_BOTTOM = 24 // pb-6 +const COLLAPSED_PADDING_BOTTOM = 12 // pb-3 + +export const Description = ({ + className, + scrollContainerId = 'marketplace-container', +}: DescriptionProps) => { const { t } = useTranslation('plugin') + const rafRef = useRef(null) + const lastProgressRef = useRef(0) + const titleRef = useRef(null) + const progress = useMotionValue(0) + const titleHeight = useMotionValue(0) + const smoothProgress = useSpring(progress, { stiffness: 260, damping: 34 }) + + useLayoutEffect(() => { + const node = titleRef.current + if (!node) + return + + const updateHeight = () => { + titleHeight.set(node.scrollHeight) + } + + updateHeight() + + if (typeof ResizeObserver === 'undefined') + return + + const observer = new ResizeObserver(updateHeight) + observer.observe(node) + return () => observer.disconnect() + }, [titleHeight]) + + useEffect(() => { + const container = document.getElementById(scrollContainerId) + if (!container) + return + + const handleScroll = () => { + // Cancel any pending animation frame + if (rafRef.current) + cancelAnimationFrame(rafRef.current) + + // Use requestAnimationFrame for smooth updates + rafRef.current = requestAnimationFrame(() => { + const scrollTop = Math.round(container.scrollTop) + const rawProgress = Math.min(Math.max(scrollTop / MAX_SCROLL, 0), 1) + const snappedProgress = rawProgress >= 0.95 + ? 1 + : rawProgress <= 0.05 + ? 0 + : Math.round(rawProgress * 100) / 100 + + if (snappedProgress !== lastProgressRef.current) { + lastProgressRef.current = snappedProgress + progress.set(snappedProgress) + } + }) + } + + container.addEventListener('scroll', handleScroll, { passive: true }) + + // Initial check + handleScroll() + + return () => { + container.removeEventListener('scroll', handleScroll) + if (rafRef.current) + cancelAnimationFrame(rafRef.current) + } + }, [progress, scrollContainerId]) + + // Calculate interpolated values + const contentOpacity = useTransform(smoothProgress, [0, 1], [1, 0]) + const contentScale = useTransform(smoothProgress, [0, 1], [1, 0.9]) + const titleMaxHeight: MotionValue = useTransform( + [smoothProgress, titleHeight], + (values: number[]) => values[1] * (1 - values[0]), + ) + const tabsMarginTop = useTransform(smoothProgress, [0, 1], [48, 0]) + const paddingTop = useTransform(smoothProgress, [0, 1], [EXPANDED_PADDING_TOP, COLLAPSED_PADDING_TOP]) + const paddingBottom = useTransform(smoothProgress, [0, 1], [EXPANDED_PADDING_BOTTOM, COLLAPSED_PADDING_BOTTOM]) return ( -
- {/* Background illustration */} - + + {/* Blue base background */} +
+ + {/* Decorative image with blend mode - showing top 1/3 of the image */} +
+ + {/* Gradient & Noise overlay */} +
{/* Content */}
-

- {t('marketplace.heroTitle')} -

-

- {t('marketplace.heroSubtitle')} -

+ {/* Title and subtitle - fade out and scale down */} + +

+ {t('marketplace.heroTitle')} +

+

+ {t('marketplace.heroSubtitle')} +

+
- {/* Plugin type switch tabs */} -
+ {/* Plugin type switch tabs - always visible */} + -
+
-
+ ) } diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 8d358708a2..190ba201b3 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,8 +1,8 @@ import type { SearchParams } from 'nuqs' import { TanstackQueryInitializer } from '@/context/query-client' -import { Description } from './description' import { HydrateQueryClient } from './hydration-server' import ListWrapper from './list/list-wrapper' +import MarketplaceHeader from './marketplace-header' type MarketplaceProps = { showInstallButton?: boolean @@ -19,7 +19,7 @@ const Marketplace = async ({ return ( - + diff --git a/web/app/components/plugins/marketplace/list/carousel.tsx b/web/app/components/plugins/marketplace/list/carousel.tsx index 397768765e..42846e7793 100644 --- a/web/app/components/plugins/marketplace/list/carousel.tsx +++ b/web/app/components/plugins/marketplace/list/carousel.tsx @@ -23,6 +23,8 @@ type ScrollState = { totalPages: number } +const SCROLL_OVERLAP_RATIO = 0.5 + const defaultScrollState: ScrollState = { canScrollLeft: false, canScrollRight: false, @@ -127,23 +129,7 @@ const Carousel = ({ scrollStateRef.current = calculateScrollState(container) }, [children, calculateScrollState]) - const scroll = useCallback((direction: 'left' | 'right') => { - const container = containerRef.current - if (!container) - return - - const scrollAmount = container.clientWidth - (itemWidth / 2) - const newScrollLeft = direction === 'left' - ? container.scrollLeft - scrollAmount - : container.scrollLeft + scrollAmount - - container.scrollTo({ - left: newScrollLeft, - behavior: 'smooth', - }) - }, [itemWidth]) - - const scrollToPage = useCallback((pageIndex: number) => { + const scrollToPage = useCallback((pageIndex: number, instant = false) => { const container = containerRef.current if (!container) return @@ -153,20 +139,51 @@ const Carousel = ({ container.scrollTo({ left: scrollLeft, - behavior: 'smooth', + 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(() => { - const nextPage = scrollState.canScrollRight - ? scrollState.currentPage + 1 - : 0 // Loop back to first page - scrollToPage(nextPage) + if (scrollState.canScrollRight) { + scrollToPage(scrollState.currentPage + 1) + } + else { + // Loop back to first page instantly (no animation) + scrollToPage(0, true) + } }, autoPlayInterval) return () => clearInterval(interval) @@ -206,13 +223,13 @@ const Carousel = ({
scroll('left')} Icon={RiArrowLeftSLine} /> scroll('right')} Icon={RiArrowRightSLine} /> diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 4ce7272e80..38db88815b 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -40,7 +40,7 @@ const List = ({ { plugins && !!plugins.length && (
diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index 2eaa96127f..0a31fb9680 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -18,7 +18,7 @@ type ListWithCollectionProps = { } const PARTNERS_COLLECTION_NAME = 'partners' -const GRID_DISPLAY_LIMIT = 8 // 2 rows × 4 columns +const GRID_DISPLAY_LIMIT = 8 // show up to 8 items const ListWithCollection = ({ marketplaceCollections, @@ -62,8 +62,8 @@ const ListWithCollection = ({ {rows.map(columnPlugins => (
{columnPlugins.map(plugin => (
@@ -77,11 +77,11 @@ const ListWithCollection = ({ } const renderGridCollection = (collection: MarketplaceCollection, plugins: Plugin[]) => { - // Other collections: Fixed 2 rows × 4 columns grid + // Other collections: responsive grid const displayPlugins = plugins.slice(0, GRID_DISPLAY_LIMIT) return ( -
+
{displayPlugins.map(plugin => (
{renderPluginCard(plugin)} diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 950b73ddbd..16278206b8 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,6 +1,13 @@ 'use client' +import type { ActivePluginType } from '../constants' import { useTranslation } from '#i18n' +import { useState } from 'react' import Loading from '@/app/components/base/loading' +import SegmentedControl from '@/app/components/base/segmented-control' +import CategoriesFilter from '../../plugin-page/filter-management/category-filter' +import TagFilter from '../../plugin-page/filter-management/tag-filter' +import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode } from '../atoms' +import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' import SortDropdown from '../sort-dropdown' import { useMarketplaceData } from '../state' import List from './index' @@ -8,10 +15,21 @@ import List from './index' type ListWrapperProps = { showInstallButton?: boolean } +type SearchScope = 'all' | 'plugins' | 'creators' +const searchScopeOptionKeys = [ + { value: 'all', textKey: 'marketplace.searchFilterAll' }, + { value: 'plugins', textKey: 'marketplace.searchFilterPlugins' }, + { value: 'creators', textKey: 'marketplace.searchFilterCreators' }, +] as const satisfies ReadonlyArray<{ value: SearchScope, textKey: 'marketplace.searchFilterAll' | 'marketplace.searchFilterPlugins' | 'marketplace.searchFilterCreators' }> + const ListWrapper = ({ showInstallButton, }: ListWrapperProps) => { const { t } = useTranslation() + const isSearchMode = useMarketplaceSearchMode() + const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags() + const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() + const [searchScope, setSearchScope] = useState('all') const { plugins, @@ -22,21 +40,55 @@ const ListWrapper = ({ isFetchingNextPage, page, } = useMarketplaceData() + const pluginsCount = pluginsTotal || 0 + const searchScopeOptions: Array<{ value: SearchScope, text: string, count: number }> = searchScopeOptionKeys.map(option => ({ + value: option.value, + text: t(option.textKey, { ns: 'plugin' }), + count: option.value === 'creators' ? 0 : pluginsCount, + })) return (
- { - plugins && ( -
-
{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}
-
- + {plugins && !isSearchMode && ( +
+
{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}
+
+ +
+ )} + {isSearchMode && ( +
+
+ { + setSearchScope(value as SearchScope) + }} + options={searchScopeOptions} + /> + { + if (categories.length === 0) { + handleActivePluginTypeChange(PLUGIN_TYPE_SEARCH_MAP.all) + return + } + handleActivePluginTypeChange(categories[categories.length - 1] as ActivePluginType) + }} + /> +
- ) - } + +
+ )} { isLoading && page === 1 && (
diff --git a/web/app/components/plugins/marketplace/marketplace-header.tsx b/web/app/components/plugins/marketplace/marketplace-header.tsx new file mode 100644 index 0000000000..f5115c1a31 --- /dev/null +++ b/web/app/components/plugins/marketplace/marketplace-header.tsx @@ -0,0 +1,20 @@ +'use client' + +import { useMarketplaceSearchMode } from './atoms' +import { Description } from './description' +import SearchResultsHeader from './search-results-header' + +type MarketplaceHeaderProps = { + descriptionClassName?: string +} + +const MarketplaceHeader = ({ descriptionClassName }: MarketplaceHeaderProps) => { + const isSearchMode = useMarketplaceSearchMode() + + if (isSearchMode) + return + + return +} + +export default MarketplaceHeader diff --git a/web/app/components/plugins/marketplace/plugin-type-icons.tsx b/web/app/components/plugins/marketplace/plugin-type-icons.tsx new file mode 100644 index 0000000000..684a1c43ec --- /dev/null +++ b/web/app/components/plugins/marketplace/plugin-type-icons.tsx @@ -0,0 +1,21 @@ +import type { ComponentType } from 'react' +import { + RiBrain2Line, + RiDatabase2Line, + RiHammerLine, + RiPuzzle2Line, + RiSpeakAiLine, +} from '@remixicon/react' +import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' +import { PluginCategoryEnum } from '../types' + +export type PluginTypeIconComponent = ComponentType<{ className?: string }> + +export const MARKETPLACE_TYPE_ICON_COMPONENTS: Record = { + [PluginCategoryEnum.tool]: RiHammerLine, + [PluginCategoryEnum.model]: RiBrain2Line, + [PluginCategoryEnum.datasource]: RiDatabase2Line, + [PluginCategoryEnum.trigger]: TriggerIcon, + [PluginCategoryEnum.agent]: RiSpeakAiLine, + [PluginCategoryEnum.extension]: RiPuzzle2Line, +} diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 916d290638..d96425bf5c 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,20 +1,16 @@ 'use client' import type { ActivePluginType } from './constants' +import type { PluginCategoryEnum } from '@/app/components/plugins/types' import { useTranslation } from '#i18n' import { RiApps2Line, RiArchive2Line, - RiBrain2Line, - RiDatabase2Line, - RiHammerLine, - RiPuzzle2Line, - RiSpeakAiLine, } from '@remixicon/react' import { useSetAtom } from 'jotai' -import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' import { cn } from '@/utils/classnames' import { searchModeAtom, useActivePluginType } from './atoms' import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { MARKETPLACE_TYPE_ICON_COMPONENTS } from './plugin-type-icons' type PluginTypeSwitchProps = { className?: string @@ -30,6 +26,15 @@ const PluginTypeSwitch = ({ const isHeroVariant = variant === 'hero' + const getTypeIcon = (value: ActivePluginType) => { + if (value === PLUGIN_TYPE_SEARCH_MAP.all) + return isHeroVariant ? : null + if (value === PLUGIN_TYPE_SEARCH_MAP.bundle) + return + const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum] + return Icon ? : null + } + const options: Array<{ value: ActivePluginType text: string @@ -38,42 +43,42 @@ const PluginTypeSwitch = ({ { value: PLUGIN_TYPE_SEARCH_MAP.all, text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }), - icon: isHeroVariant ? : null, + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all), }, { value: PLUGIN_TYPE_SEARCH_MAP.model, text: t('category.models', { ns: 'plugin' }), - icon: , + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model), }, { value: PLUGIN_TYPE_SEARCH_MAP.tool, text: t('category.tools', { ns: 'plugin' }), - icon: , + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool), }, { value: PLUGIN_TYPE_SEARCH_MAP.datasource, text: t('category.datasources', { ns: 'plugin' }), - icon: , + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource), }, { value: PLUGIN_TYPE_SEARCH_MAP.trigger, text: t('category.triggers', { ns: 'plugin' }), - icon: , + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger), }, { value: PLUGIN_TYPE_SEARCH_MAP.agent, text: t('category.agents', { ns: 'plugin' }), - icon: , + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent), }, { value: PLUGIN_TYPE_SEARCH_MAP.extension, text: t('category.extensions', { ns: 'plugin' }), - icon: , + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension), }, { value: PLUGIN_TYPE_SEARCH_MAP.bundle, text: t('category.bundles', { ns: 'plugin' }), - icon: , + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle), }, ] diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx index 85be82cb33..8f150957d0 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx @@ -1,8 +1,11 @@ import type { Tag } from '@/app/components/plugins/hooks' +import type { Plugin } from '@/app/components/plugins/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '../../types' import SearchBox from './index' import SearchBoxWrapper from './search-box-wrapper' +import SearchDropdown from './search-dropdown' import MarketplaceTrigger from './trigger/marketplace' import ToolSelectorTrigger from './trigger/tool-selector' @@ -13,32 +16,72 @@ import ToolSelectorTrigger from './trigger/tool-selector' // Mock i18n translation hook vi.mock('#i18n', () => ({ useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { + t: (key: string, options?: { ns?: string, num?: number, author?: string }) => { // Build full key with namespace prefix if provided const fullKey = options?.ns ? `${options.ns}.${key}` : key const translations: Record = { 'pluginTags.allTags': 'All Tags', 'pluginTags.searchTags': 'Search tags', 'plugin.searchPlugins': 'Search plugins', + 'plugin.install': `${options?.num || 0} installs`, + 'plugin.marketplace.searchDropdown.plugins': 'Plugins', + 'plugin.marketplace.searchDropdown.showAllResults': 'Show all search results', + 'plugin.marketplace.searchDropdown.enter': 'Enter', + 'plugin.marketplace.searchDropdown.byAuthor': `by ${options?.author || ''}`, } return translations[fullKey] || key }, }), })) +vi.mock('ahooks', () => ({ + useDebounce: (value: string) => value, +})) + +vi.mock('jotai', async () => { + const actual = await vi.importActual('jotai') + return { + ...actual, + useSetAtom: () => vi.fn(), + } +}) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (value: Record | string) => { + if (typeof value === 'string') + return value + return value.en_US || Object.values(value)[0] || '' + }, +})) + // Mock marketplace state hooks -const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => { +const { + mockSearchPluginText, + mockHandleSearchPluginTextChange, + mockFilterPluginTags, + mockHandleFilterPluginTagsChange, + mockActivePluginType, + mockSortValue, +} = vi.hoisted(() => { return { mockSearchPluginText: '', mockHandleSearchPluginTextChange: vi.fn(), mockFilterPluginTags: [] as string[], mockHandleFilterPluginTagsChange: vi.fn(), + mockActivePluginType: 'all', + mockSortValue: { + sortBy: 'install_count', + sortOrder: 'DESC', + }, } }) vi.mock('../atoms', () => ({ useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], + useActivePluginType: () => [mockActivePluginType, vi.fn()], + useMarketplaceSortValue: () => mockSortValue, + searchModeAtom: {}, })) // Mock useTags hook @@ -60,8 +103,57 @@ vi.mock('@/app/components/plugins/hooks', () => ({ tags: mockTags, tagsMap: mockTagsMap, }), + useCategories: () => ({ + categoriesMap: { + 'tool': { name: 'tool', label: 'Tool' }, + 'model': { name: 'model', label: 'Model' }, + 'datasource': { name: 'datasource', label: 'Data Source' }, + 'trigger': { name: 'trigger', label: 'Trigger' }, + 'agent-strategy': { name: 'agent-strategy', label: 'Agent Strategy' }, + 'extension': { name: 'extension', label: 'Extension' }, + 'bundle': { name: 'bundle', label: 'Bundle' }, + }, + }), })) +let mockDropdownPlugins: Plugin[] = [] +vi.mock('../query', () => ({ + useMarketplacePlugins: () => ({ + data: { pages: [{ plugins: mockDropdownPlugins }] }, + isLoading: false, + }), +})) + +const createPlugin = (overrides: Partial = {}): Plugin => ({ + type: 'plugin', + org: 'dropbox', + author: 'dropbox', + name: 'dropbox-search', + plugin_id: 'plugin-1', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'pkg-1', + icon: 'https://example.com/icon.png', + verified: false, + label: { en_US: 'Dropbox search' }, + brief: { en_US: 'Interact with Dropbox files.' }, + description: { en_US: 'Interact with Dropbox files.' }, + introduction: '', + repository: '', + category: PluginCategoryEnum.tool, + install_count: 206, + endpoint: { + settings: [], + }, + tags: [], + badges: [], + verification: { + authorized_category: 'community', + }, + from: 'marketplace', + ...overrides, +}) + // Mock portal-to-follow-elem with shared open state let mockPortalOpenState = false @@ -115,6 +207,7 @@ describe('SearchBox', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false + mockDropdownPlugins = [] }) // ================================ @@ -424,6 +517,64 @@ describe('SearchBox', () => { expect(onSearchChange).toHaveBeenCalledWith(' ') }) }) + + // ================================ + // Submission Tests + // ================================ + describe('Submission', () => { + it('should call onSearchSubmit when pressing Enter', () => { + const onSearchSubmit = vi.fn() + render() + + const input = screen.getByRole('textbox') + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onSearchSubmit).toHaveBeenCalledTimes(1) + }) + }) +}) + +// ================================ +// SearchDropdown Component Tests +// ================================ +describe('SearchDropdown', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render plugin items and metadata', () => { + render( + , + ) + + expect(screen.getByText('Plugins')).toBeInTheDocument() + expect(screen.getByText('Dropbox search')).toBeInTheDocument() + expect(screen.getByText('Tool')).toBeInTheDocument() + expect(screen.getByText('206 installs')).toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('should call onShowAll when clicking show all results', () => { + const onShowAll = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('Show all search results')) + + expect(onShowAll).toHaveBeenCalledTimes(1) + }) + }) }) // ================================ @@ -433,6 +584,7 @@ describe('SearchBoxWrapper', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false + mockDropdownPlugins = [] }) describe('Rendering', () => { @@ -457,12 +609,22 @@ describe('SearchBoxWrapper', () => { }) describe('Hook Integration', () => { - it('should call handleSearchPluginTextChange when search changes', () => { + it('should not commit search when input changes', () => { render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new search' } }) + expect(mockHandleSearchPluginTextChange).not.toHaveBeenCalled() + }) + + it('should commit search when pressing Enter', () => { + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'new search' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search') }) }) diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx index b6e1f8ee70..e595c43c5b 100644 --- a/web/app/components/plugins/marketplace/search-box/index.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.tsx @@ -8,6 +8,9 @@ import TagsFilter from './tags-filter' type SearchBoxProps = { search: string onSearchChange: (search: string) => void + onSearchSubmit?: () => void + onSearchFocus?: () => void + onSearchBlur?: () => void wrapperClassName?: string inputClassName?: string tags: string[] @@ -22,6 +25,9 @@ type SearchBoxProps = { const SearchBox = ({ search, onSearchChange, + onSearchSubmit, + onSearchFocus, + onSearchBlur, wrapperClassName, inputClassName, tags, @@ -58,6 +64,12 @@ const SearchBox = ({ onChange={(e) => { onSearchChange(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') + onSearchSubmit?.() + }} + onFocus={onSearchFocus} + onBlur={onSearchBlur} placeholder={placeholder} /> { @@ -89,6 +101,12 @@ const SearchBox = ({ onChange={(e) => { onSearchChange(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') + onSearchSubmit?.() + }} + onFocus={onSearchFocus} + onBlur={onSearchBlur} placeholder={placeholder} /> { diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index 39f2f1bdc6..3cd5cff0be 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,9 +1,28 @@ 'use client' +import type { PluginsSearchParams } from '../types' import { useTranslation } from '#i18n' +import { useDebounce } from 'ahooks' +import { useSetAtom } from 'jotai' +import { useMemo, useState } from 'react' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' import { cn } from '@/utils/classnames' -import { useFilterPluginTags, useSearchPluginText } from '../atoms' +import { + searchModeAtom, + useActivePluginType, + useFilterPluginTags, + useMarketplaceSortValue, + useSearchPluginText, +} from '../atoms' +import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' +import { useMarketplacePlugins } from '../query' +import { getMarketplaceListFilterType } from '../utils' import SearchBox from './index' +import SearchDropdown from './search-dropdown' type SearchBoxWrapperProps = { wrapperClassName?: string @@ -16,18 +35,92 @@ const SearchBoxWrapper = ({ const { t } = useTranslation() const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText() const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() + const sort = useMarketplaceSortValue() + const setSearchMode = useSetAtom(searchModeAtom) + const committedSearch = searchPluginText || '' + const [draftSearch, setDraftSearch] = useState(committedSearch) + const [isFocused, setIsFocused] = useState(false) + const [isHoveringDropdown, setIsHoveringDropdown] = useState(false) + const debouncedDraft = useDebounce(draftSearch, { wait: 300 }) + const hasDraft = !!debouncedDraft.trim() + + const dropdownQueryParams = useMemo(() => { + if (!hasDraft) + return undefined + const filterType = getMarketplaceListFilterType(activePluginType) as PluginsSearchParams['type'] + return { + query: debouncedDraft.trim(), + category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, + tags: filterPluginTags, + sort_by: sort.sortBy, + sort_order: sort.sortOrder, + type: filterType, + page_size: 3, + } + }, [activePluginType, debouncedDraft, filterPluginTags, hasDraft, sort.sortBy, sort.sortOrder]) + + const dropdownQuery = useMarketplacePlugins(dropdownQueryParams) + const dropdownPlugins = dropdownQuery.data?.pages[0]?.plugins || [] + + const handleSubmit = () => { + const trimmed = draftSearch.trim() + if (!trimmed) + return + handleSearchPluginTextChange(trimmed) + setSearchMode(true) + setIsFocused(false) + } + + const inputValue = isFocused ? draftSearch : committedSearch + const isDropdownOpen = hasDraft && (isFocused || isHoveringDropdown) return ( - + + +
+ { + setDraftSearch(committedSearch) + setIsFocused(true) + }} + onSearchBlur={() => { + if (!isHoveringDropdown) + setIsFocused(false) + }} + tags={filterPluginTags} + onTagsChange={handleFilterPluginTagsChange} + placeholder={t('searchPlugins', { ns: 'plugin' })} + usedInMarketplace + /> +
+
+ setIsHoveringDropdown(true)} + onMouseLeave={() => setIsHoveringDropdown(false)} + onMouseDown={(event) => { + event.preventDefault() + }} + > + + +
) } diff --git a/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx b/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx new file mode 100644 index 0000000000..7c8612b008 --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx @@ -0,0 +1,106 @@ +import type { Plugin } from '@/app/components/plugins/types' +import { useTranslation } from '#i18n' +import { RiArrowRightLine } from '@remixicon/react' +import Loading from '@/app/components/base/loading' +import { useCategories } from '@/app/components/plugins/hooks' +import { useRenderI18nObject } from '@/hooks/use-i18n' +import { cn } from '@/utils/classnames' +import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons' +import { getPluginDetailLinkInMarketplace } from '../../utils' + +type SearchDropdownProps = { + query: string + plugins: Plugin[] + onShowAll: () => void + isLoading?: boolean +} + +const SearchDropdown = ({ + query, + plugins, + onShowAll, + isLoading = false, +}: SearchDropdownProps) => { + const { t } = useTranslation() + const getValueFromI18nObject = useRenderI18nObject() + const { categoriesMap } = useCategories(true) + + return ( +
+
+ {isLoading && !plugins.length && ( +
+ +
+ )} + {!!plugins.length && ( +
+
+ {t('marketplace.searchDropdown.plugins', { ns: 'plugin' })} +
+
+ {plugins.map((plugin) => { + const title = getValueFromI18nObject(plugin.label) || plugin.name + const description = getValueFromI18nObject(plugin.brief) || '' + const categoryLabel = categoriesMap[plugin.category]?.label || plugin.category + const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 }) + const author = plugin.org || plugin.author || '' + const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category] + return ( + +
+ {title} +
+
+
{title}
+ {!!description && ( +
{description}
+ )} +
+
+ {TypeIcon && } + {categoryLabel} +
+ · + + {t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author })} + + · + {installLabel} +
+
+
+ ) + })} +
+
+ )} +
+
+ +
+
+ ) +} + +export default SearchDropdown diff --git a/web/app/components/plugins/marketplace/search-box/trigger/index.ts b/web/app/components/plugins/marketplace/search-box/trigger/index.ts new file mode 100644 index 0000000000..5a53d5dd14 --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/trigger/index.ts @@ -0,0 +1,2 @@ +export { default as MarketplaceTrigger } from './marketplace' +export { default as ToolSelectorTrigger } from './tool-selector' diff --git a/web/app/components/plugins/marketplace/search-results-header.tsx b/web/app/components/plugins/marketplace/search-results-header.tsx new file mode 100644 index 0000000000..74c223bd76 --- /dev/null +++ b/web/app/components/plugins/marketplace/search-results-header.tsx @@ -0,0 +1,30 @@ +'use client' + +import { useTranslation } from '#i18n' +import { useSearchPluginText } from './atoms' + +const SearchResultsHeader = () => { + const { t } = useTranslation('plugin') + const [searchPluginText] = useSearchPluginText() + + return ( +
+
+ {t('marketplace.searchBreadcrumbMarketplace')} + / + {t('marketplace.searchBreadcrumbSearch')} +
+
+
+ {t('marketplace.searchResultsFor')} +
+
+ {searchPluginText || ''} + +
+
+
+ ) +} + +export default SearchResultsHeader diff --git a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx deleted file mode 100644 index ac1c027a2d..0000000000 --- a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client' - -import { cn } from '@/utils/classnames' -import SearchBoxWrapper from './search-box/search-box-wrapper' - -type StickySearchAndSwitchWrapperProps = { - pluginTypeSwitchClassName?: string -} - -const StickySearchAndSwitchWrapper = ({ - pluginTypeSwitchClassName, -}: StickySearchAndSwitchWrapperProps) => { - const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-') - - return ( -
- -
- ) -} - -export default StickySearchAndSwitchWrapper diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index 30ece18ac3..10050617b4 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -151,7 +151,7 @@ const PluginPage = ({ onChange={setActiveTab} options={options} /> - + {!isPluginsTab && }
{ diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index c7e4eb4a05..b66893022a 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -200,10 +200,22 @@ "marketplace.heroTitle": "Discover. Extend. Build.", "marketplace.installs": "installs", "marketplace.moreFrom": "More from Marketplace", - "marketplace.ourTopPicks": "Our top picks to get you started", "marketplace.noPluginFound": "No plugin found", + "marketplace.ourTopPicks": "Our top picks to get you started", "marketplace.partnerTip": "Verified by a Dify partner", "marketplace.pluginsResult": "{{num}} results", + "marketplace.searchBreadcrumbMarketplace": "Marketplace", + "marketplace.searchBreadcrumbSearch": "Search", + "marketplace.searchDropdown.byAuthor": "by {{author}}", + "marketplace.searchDropdown.enter": "Enter", + "marketplace.searchDropdown.plugins": "Plugins", + "marketplace.searchDropdown.showAllResults": "Show all search results", + "marketplace.searchFilterAll": "All", + "marketplace.searchFilterCreators": "Creators", + "marketplace.searchFilterPlugins": "Plugins", + "marketplace.searchFilterTags": "Tags", + "marketplace.searchFilterTypes": "Types", + "marketplace.searchResultsFor": "Results for", "marketplace.sortBy": "Sort by", "marketplace.sortOption.firstReleased": "First Released", "marketplace.sortOption.mostPopular": "Most Popular", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index e2b625dc8a..c97744fd33 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -200,10 +200,22 @@ "marketplace.heroTitle": "探索。扩展。构建。", "marketplace.installs": "次安装", "marketplace.moreFrom": "更多来自市场", - "marketplace.ourTopPicks": "我们精选推荐", "marketplace.noPluginFound": "未找到插件", + "marketplace.ourTopPicks": "我们精选推荐", "marketplace.partnerTip": "此插件由 Dify 合作伙伴认证", "marketplace.pluginsResult": "{{num}} 个插件结果", + "marketplace.searchBreadcrumbMarketplace": "市场", + "marketplace.searchBreadcrumbSearch": "搜索", + "marketplace.searchDropdown.byAuthor": "由 {{author}} 提供", + "marketplace.searchDropdown.enter": "输入", + "marketplace.searchDropdown.plugins": "插件", + "marketplace.searchDropdown.showAllResults": "显示所有搜索结果", + "marketplace.searchFilterAll": "全部", + "marketplace.searchFilterCreators": "创作者", + "marketplace.searchFilterPlugins": "插件", + "marketplace.searchFilterTags": "标签", + "marketplace.searchFilterTypes": "类型", + "marketplace.searchResultsFor": "搜索结果", "marketplace.sortBy": "排序方式", "marketplace.sortOption.firstReleased": "首次发布", "marketplace.sortOption.mostPopular": "最受欢迎", diff --git a/web/package.json b/web/package.json index b8f8e3499f..478a493231 100644 --- a/web/package.json +++ b/web/package.json @@ -118,6 +118,7 @@ "mermaid": "11.11.0", "mime": "4.1.0", "mitt": "3.0.1", + "motion": "12.31.0", "negotiator": "1.0.0", "next": "16.1.5", "next-themes": "0.4.6", diff --git a/web/public/marketplace/hero-bg.jpg b/web/public/marketplace/hero-bg.jpg new file mode 100644 index 0000000000..22de60a2be Binary files /dev/null and b/web/public/marketplace/hero-bg.jpg differ diff --git a/web/public/marketplace/hero-gradient-noise.svg b/web/public/marketplace/hero-gradient-noise.svg new file mode 100644 index 0000000000..85eddea08e --- /dev/null +++ b/web/public/marketplace/hero-gradient-noise.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +