From 3fb85f76ada979aaafd66f781bd074e29f03b591 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 28 Jul 2026 15:07:38 +0800 Subject: [PATCH] feat: complete marketplace trending carousel --- .../home/__tests__/home-trending.spec.tsx | 208 +++++ .../home/assets/dify-updates-art.png | Bin 0 -> 21416 bytes .../plugins/marketplace/home/banners.spec.ts | 167 ++-- .../plugins/marketplace/home/banners.ts | 241 +++++- .../plugins/marketplace/home/home-header.tsx | 2 +- .../home/home-trending-indicator.module.css | 33 - .../marketplace/home/home-trending.module.css | 108 +++ .../marketplace/home/home-trending.tsx | 748 ++++++++++++------ .../plugins/marketplace/home/index.tsx | 16 +- .../components/plugins/marketplace/index.tsx | 61 +- web/app/styles/tailwind-core.css | 2 + web/i18n/en-US/plugin.json | 3 + web/i18n/zh-Hans/plugin.json | 3 + web/i18n/zh-Hant/plugin.json | 3 + 14 files changed, 1177 insertions(+), 418 deletions(-) create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/assets/dify-updates-art.png delete mode 100644 web/app/components/plugins/marketplace/home/home-trending-indicator.module.css create mode 100644 web/app/components/plugins/marketplace/home/home-trending.module.css diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx new file mode 100644 index 00000000000..9b1cbc34254 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -0,0 +1,208 @@ +import type { PluginBanner } from '../banners' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import HomeTrending from '../home-trending' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: (namespace: string) => ({ + t: withSelectorKey((key: string) => `${namespace}.${key}`), + }), + } +}) + +vi.mock('@/app/components/plugins/base/badges/partner', () => ({ + default: () => , +})) + +vi.mock('@/app/components/plugins/base/badges/verified', () => ({ + default: () => , +})) + +const banners: PluginBanner[] = [ + { + id: 'recommend', + style_type: 'recommend', + title: 'Trending', + sort: 0, + language: 'en', + content: { + theme_type: 'hottest', + heading: 'Popular plugins', + description: 'Chosen from real usage.', + cards: [ + { + item_type: 'plugin', + item_id: 'langgenius/dropbox', + display_name: 'Dropbox', + icon_url: '/api/v1/plugins/langgenius/dropbox/icon', + creator: 'langgenius', + badges: ['partner', 'verified'], + link: '/plugins/langgenius/dropbox', + card_position: 0, + }, + { + item_type: 'plugin', + item_id: 'langgenius/zapier', + display_name: 'Zapier', + link: '/plugins/langgenius/zapier', + card_position: 1, + }, + { + item_type: 'plugin', + item_id: 'langgenius/notion', + display_name: 'Notion', + link: '/plugins/langgenius/notion', + card_position: 2, + }, + { + item_type: 'plugin', + item_id: 'langgenius/slack', + display_name: 'Slack', + link: '/plugins/langgenius/slack', + card_position: 3, + }, + ], + }, + }, + { + id: 'blog', + style_type: 'blog', + title: 'Dify Updates', + sort: 1, + language: 'en', + content: { + blog_title: 'Dify v1.9 new launch', + subtitle: 'New Agent node support', + description: 'Build agent workflows with the new Agent node.', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + { + id: 'event', + style_type: 'event', + title: 'Duck Duck Go', + sort: 2, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/duckduckgo.png', + mobile: '/api/v1/banners/images/banners/duckduckgo-mobile.png', + }, + link: 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo', + alt_text: 'DuckDuckGo plugin', + }, + }, +] + +describe('HomeTrending', () => { + it('renders and switches between the three API-backed banner layouts', async () => { + const user = userEvent.setup() + + render() + + expect(screen.getByRole('heading', { name: 'Popular plugins' })).toBeInTheDocument() + const recommendationSlide = screen.getByRole('group', { name: 'Trending' }) + expect( + within(recommendationSlide) + .getAllByRole('link') + .map((link) => link.getAttribute('aria-label')), + ).toEqual(['Dropbox', 'Zapier', 'Notion', 'Slack']) + + await user.click(screen.getByRole('button', { name: 'Dify Updates' })) + + expect(screen.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeInTheDocument() + expect( + screen.getByRole('link', { + name: 'Read more about Dify v1.9 new launch', + }), + ).toHaveAttribute('href', 'https://dify.ai/blog') + + await user.click(screen.getByRole('button', { name: 'Duck Duck Go' })) + + expect(screen.getByRole('link', { name: 'DuckDuckGo plugin' })).toHaveAttribute( + 'href', + 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo', + ) + }) + + it('switches to the selected slide from the pagination with the keyboard', async () => { + const user = userEvent.setup() + + render() + + const duckDuckGoButton = screen.getByRole('button', { name: 'Duck Duck Go' }) + + duckDuckGoButton.focus() + await user.keyboard('{Enter}') + + expect(duckDuckGoButton).toHaveAttribute('aria-current', 'true') + expect(screen.getByRole('button', { name: 'Trending' })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('group', { name: 'Duck Duck Go' })).toHaveAttribute( + 'aria-hidden', + 'false', + ) + }) + + it('toggles the carousel between paused and playing states', async () => { + const user = userEvent.setup() + + render() + + const pauseButton = screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPause', + }) + + pauseButton.focus() + await user.keyboard('{Enter}') + + const playButton = screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPlay', + }) + + playButton.focus() + await user.keyboard(' ') + + expect( + screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPause', + }), + ).toBeInTheDocument() + }) + + it('starts with autoplay paused when reduced motion is enabled', () => { + const matchMedia = vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) + + render() + + expect( + screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPlay', + }), + ).toBeInTheDocument() + + matchMedia.mockRestore() + }) + + it('renders no carousel when the API returns no banners', () => { + render() + + expect( + screen.queryByRole('region', { + name: 'plugin.marketplace.home.trendingTitle', + }), + ).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png b/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png new file mode 100644 index 0000000000000000000000000000000000000000..12d192cef3619cefe753668e4c27f6d6bb84adc4 GIT binary patch literal 21416 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 literal 0 HcmV?d00001 diff --git a/web/app/components/plugins/marketplace/home/banners.spec.ts b/web/app/components/plugins/marketplace/home/banners.spec.ts index dbc9f62d1ae..65dfc1f4581 100644 --- a/web/app/components/plugins/marketplace/home/banners.spec.ts +++ b/web/app/components/plugins/marketplace/home/banners.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { marketplaceClient } from '@/service/client' -import { fetchPluginRecommendBanners } from './banners' +import { fetchPluginBanners } from './banners' vi.mock('@/service/client', () => ({ marketplaceClient: { @@ -12,12 +12,12 @@ vi.mock('@/service/client', () => ({ const mockedListBanners = vi.mocked(marketplaceClient.banners.list) -describe('fetchPluginRecommendBanners', () => { +describe('fetchPluginBanners', () => { beforeEach(() => { mockedListBanners.mockReset() }) - it('normalizes, sorts, and limits recommend banners from the public contract', async () => { + it('normalizes every public banner style in API sort order', async () => { mockedListBanners.mockResolvedValue({ code: 0, msg: 'success', @@ -26,31 +26,44 @@ describe('fetchPluginRecommendBanners', () => { { id: 'event', style_type: 'event', - title: 'Event', - sort: 0, - language: 'en', - content: {}, - }, - { - id: 'recommend-2', - style_type: 'recommend', - title: 'Second', - sort: 2, + title: 'Dify Event', + sort: 3, language: 'en', content: { + images: { + desktop: '/api/v1/banners/images/banners/event.png', + mobile: '/api/v1/banners/images/banners/event-mobile.png', + }, + link: 'https://dify.ai/events', + alt_text: 'Dify Event', + activity_id: 'event-1', + }, + }, + { + id: 'recommend', + style_type: 'recommend', + title: 'Trending Now', + sort: 1, + language: 'en', + content: { + theme_type: 'hottest', + heading: 'Popular plugins', + description: 'Chosen from real usage.', cards: [ { item_type: 'plugin', - item_id: 'langgenius/fifth', - display_name: 'Fifth', - link: '/plugins/langgenius/fifth', - card_position: 4, + item_id: 'langgenius/fourth', + display_name: 'Fourth', + link: '/plugins/langgenius/fourth', + card_position: 3, }, { item_type: 'plugin', item_id: 'langgenius/first', display_name: 'First', icon_url: '/api/v1/plugins/langgenius/first/icon', + creator: 'langgenius', + badges: ['verified', 'partner', 'unknown'], link: '/plugins/langgenius/first', card_position: 0, }, @@ -68,39 +81,51 @@ describe('fetchPluginRecommendBanners', () => { link: '/plugins/langgenius/second', card_position: 1, }, - { - item_type: 'plugin', - item_id: 'langgenius/fourth', - display_name: 'Fourth', - link: '/plugins/langgenius/fourth', - card_position: 3, - }, ], }, }, { - id: 'recommend-1', - style_type: 'recommend', - title: 'First', - sort: 1, + id: 'ad', + style_type: 'ad', + title: 'Partner campaign', + sort: 4, language: 'en', content: { - cards: [ - { - item_type: 'plugin', - item_id: 'langgenius/agent', - display_name: 'Agent', - link: '/plugins/langgenius/agent', - card_position: 0, - }, - ], + images: { + desktop: '/api/v1/banners/images/banners/ad.webp', + }, + link: 'https://example.com', + partner_id: 'partner-1', + campaign_id: 'campaign-1', }, }, + { + id: 'blog', + style_type: 'blog', + title: 'Dify Updates', + sort: 2, + language: 'en', + content: { + blog_title: 'Dify v1.9 new launch', + subtitle: 'New Agent node support', + description: 'Build agent workflows with the new Agent node.', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + { + id: 'unsupported', + style_type: 'popup', + title: 'Unsupported', + sort: 0, + language: 'en', + content: {}, + }, ], }, }) - const banners = await fetchPluginRecommendBanners('en-US') + const banners = await fetchPluginBanners('en-US') expect(mockedListBanners).toHaveBeenCalledWith({ query: { @@ -108,14 +133,68 @@ describe('fetchPluginRecommendBanners', () => { language: 'en-US', }, }) - expect(banners.map(banner => banner.id)).toEqual(['recommend-1', 'recommend-2']) - expect(banners[1]!.content.cards.map(card => card.display_name)) - .toEqual(['First', 'Second', 'Third', 'Fourth']) + expect(banners.map((banner) => banner.id)).toEqual(['recommend', 'blog', 'event', 'ad']) + + const recommend = banners[0] + expect(recommend?.style_type).toBe('recommend') + if (recommend?.style_type === 'recommend') { + expect(recommend.content.cards.map((card) => card.display_name)).toEqual([ + 'First', + 'Second', + 'Third', + 'Fourth', + ]) + expect(recommend.content.cards[0]).toMatchObject({ + creator: 'langgenius', + badges: ['verified', 'partner'], + }) + } + + const event = banners[2] + expect(event?.style_type).toBe('event') + if (event?.style_type === 'event') { + expect(event.content.images).toEqual({ + desktop: '/api/v1/banners/images/banners/event.png', + mobile: '/api/v1/banners/images/banners/event-mobile.png', + }) + } }) - it('returns no banners for an empty response', async () => { - mockedListBanners.mockResolvedValue('') + it('drops malformed banners and returns no placeholders for an empty response', async () => { + mockedListBanners + .mockResolvedValueOnce({ + data: { + banners: [ + { + id: 'empty-recommend', + style_type: 'recommend', + title: 'Empty', + sort: 0, + language: 'en', + content: { + theme_type: 'hottest', + cards: [], + }, + }, + { + id: 'event-without-desktop', + style_type: 'event', + title: 'Broken', + sort: 1, + language: 'en', + content: { + images: { + mobile: '/api/v1/banners/images/banners/mobile.png', + }, + link: 'https://example.com', + }, + }, + ], + }, + }) + .mockResolvedValueOnce('') - await expect(fetchPluginRecommendBanners('en-US')).resolves.toEqual([]) + await expect(fetchPluginBanners('en-US')).resolves.toEqual([]) + await expect(fetchPluginBanners('en-US')).resolves.toEqual([]) }) }) diff --git a/web/app/components/plugins/marketplace/home/banners.ts b/web/app/components/plugins/marketplace/home/banners.ts index a6c4ff15c38..8f909e40fe3 100644 --- a/web/app/components/plugins/marketplace/home/banners.ts +++ b/web/app/components/plugins/marketplace/home/banners.ts @@ -1,8 +1,14 @@ import { marketplaceClient } from '@/service/client' -const MAX_TRENDING_PAGES = 3 const MAX_CARDS_PER_PAGE = 4 +type BannerBase = { + id: string + title: string + sort: number + language: string +} + export type BannerRecommendCard = { item_type: 'plugin' | 'template' item_id: string @@ -10,18 +16,16 @@ export type BannerRecommendCard = { icon_url?: string icon?: string icon_background?: string + creator?: string + badges?: Array<'partner' | 'verified'> link: string card_position: number } -export type BannerRecommend = { - id: string +export type BannerRecommend = BannerBase & { style_type: 'recommend' - title: string - sort: number - language: string content: { - theme_type?: string + theme_type: 'newest' | 'hottest' | 'partner' heading?: string subheadings?: string[] description?: string @@ -29,27 +33,90 @@ export type BannerRecommend = { } } +export type BannerBlog = BannerBase & { + style_type: 'blog' + content: { + blog_title: string + subtitle?: string + description?: string + link: string + link_target_type: 'blog' | 'github' + } +} + +type BannerImageContent = { + images: { + desktop: string + tablet?: string + mobile?: string + } + link: string + alt_text?: string + activity_id?: string +} + +export type BannerEvent = BannerBase & { + style_type: 'event' + content: BannerImageContent +} + +export type BannerAd = BannerBase & { + style_type: 'ad' + content: BannerImageContent & { + partner_id?: string + campaign_id?: string + } +} + +export type PluginBanner = BannerRecommend | BannerBlog | BannerEvent | BannerAd + const isRecord = (value: unknown): value is Record => { return typeof value === 'object' && value !== null && !Array.isArray(value) } -const parseRecommendCard = (value: unknown): BannerRecommendCard | null => { - if (!isRecord(value)) +const parseBannerBase = (value: Record): BannerBase | null => { + if ( + typeof value.id !== 'string' || + !value.id || + typeof value.title !== 'string' || + !value.title || + typeof value.sort !== 'number' || + typeof value.language !== 'string' || + !value.language + ) { return null + } + + return { + id: value.id, + title: value.title, + sort: value.sort, + language: value.language, + } +} + +const parseRecommendCard = (value: unknown): BannerRecommendCard | null => { + if (!isRecord(value)) return null const itemType = value.item_type const itemId = value.item_id const displayName = value.display_name if ( - (itemType !== 'plugin' && itemType !== 'template') - || typeof itemId !== 'string' - || !itemId - || typeof displayName !== 'string' - || !displayName + (itemType !== 'plugin' && itemType !== 'template') || + typeof itemId !== 'string' || + !itemId || + typeof displayName !== 'string' || + !displayName ) { return null } + const badges = Array.isArray(value.badges) + ? value.badges.filter( + (badge): badge is 'partner' | 'verified' => badge === 'partner' || badge === 'verified', + ) + : undefined + return { item_type: itemType, item_id: itemId, @@ -57,65 +124,153 @@ const parseRecommendCard = (value: unknown): BannerRecommendCard | null => { icon_url: typeof value.icon_url === 'string' ? value.icon_url : undefined, icon: typeof value.icon === 'string' ? value.icon : undefined, icon_background: typeof value.icon_background === 'string' ? value.icon_background : undefined, + creator: typeof value.creator === 'string' ? value.creator : undefined, + badges, link: typeof value.link === 'string' ? value.link : '', card_position: typeof value.card_position === 'number' ? value.card_position : 0, } } -const parseRecommendBanner = (value: unknown): BannerRecommend | null => { - if (!isRecord(value) || value.style_type !== 'recommend' || !isRecord(value.content)) - return null +const parseRecommendBanner = ( + base: BannerBase, + content: Record, +): BannerRecommend | null => { + const themeType = content.theme_type + if (themeType !== 'newest' && themeType !== 'hottest' && themeType !== 'partner') return null - const cards = Array.isArray(value.content.cards) - ? value.content.cards + const cards = Array.isArray(content.cards) + ? content.cards .map(parseRecommendCard) .filter((card): card is BannerRecommendCard => Boolean(card)) .sort((a, b) => a.card_position - b.card_position) .slice(0, MAX_CARDS_PER_PAGE) : [] - if ( - typeof value.id !== 'string' - || typeof value.title !== 'string' - || typeof value.sort !== 'number' - || typeof value.language !== 'string' - || cards.length === 0 - ) { - return null - } + if (cards.length === 0) return null - const subheadings = Array.isArray(value.content.subheadings) - ? value.content.subheadings.filter((item): item is string => typeof item === 'string') + const subheadings = Array.isArray(content.subheadings) + ? content.subheadings.filter((item): item is string => typeof item === 'string') : undefined return { - id: value.id, + ...base, style_type: 'recommend', - title: value.title, - sort: value.sort, - language: value.language, content: { - theme_type: typeof value.content.theme_type === 'string' ? value.content.theme_type : undefined, - heading: typeof value.content.heading === 'string' ? value.content.heading : undefined, + theme_type: themeType, + heading: typeof content.heading === 'string' ? content.heading : undefined, subheadings, - description: typeof value.content.description === 'string' ? value.content.description : undefined, + description: typeof content.description === 'string' ? content.description : undefined, cards, }, } } -export const normalizePluginRecommendBanners = (response: unknown): BannerRecommend[] => { +const parseBlogBanner = (base: BannerBase, content: Record): BannerBlog | null => { + const linkTargetType = content.link_target_type + if ( + typeof content.blog_title !== 'string' || + !content.blog_title || + typeof content.link !== 'string' || + !content.link || + (linkTargetType !== 'blog' && linkTargetType !== 'github') + ) { + return null + } + + return { + ...base, + style_type: 'blog', + content: { + blog_title: content.blog_title, + subtitle: typeof content.subtitle === 'string' ? content.subtitle : undefined, + description: typeof content.description === 'string' ? content.description : undefined, + link: content.link, + link_target_type: linkTargetType, + }, + } +} + +const parseImageBanner = ( + base: BannerBase, + styleType: 'event' | 'ad', + content: Record, +): BannerEvent | BannerAd | null => { + if ( + !isRecord(content.images) || + typeof content.images.desktop !== 'string' || + !content.images.desktop || + typeof content.link !== 'string' || + !content.link + ) { + return null + } + + const imageContent: BannerImageContent = { + images: { + desktop: content.images.desktop, + tablet: + typeof content.images.tablet === 'string' && content.images.tablet + ? content.images.tablet + : undefined, + mobile: + typeof content.images.mobile === 'string' && content.images.mobile + ? content.images.mobile + : undefined, + }, + link: content.link, + alt_text: typeof content.alt_text === 'string' ? content.alt_text : undefined, + activity_id: typeof content.activity_id === 'string' ? content.activity_id : undefined, + } + + if (styleType === 'event') { + return { + ...base, + style_type: 'event', + content: imageContent, + } + } + + return { + ...base, + style_type: 'ad', + content: { + ...imageContent, + partner_id: typeof content.partner_id === 'string' ? content.partner_id : undefined, + campaign_id: typeof content.campaign_id === 'string' ? content.campaign_id : undefined, + }, + } +} + +const parsePluginBanner = (value: unknown): PluginBanner | null => { + if (!isRecord(value) || !isRecord(value.content)) return null + + const base = parseBannerBase(value) + if (!base) return null + + switch (value.style_type) { + case 'recommend': + return parseRecommendBanner(base, value.content) + case 'blog': + return parseBlogBanner(base, value.content) + case 'event': + case 'ad': + return parseImageBanner(base, value.style_type, value.content) + default: + return null + } +} + +export const normalizePluginBanners = (response: unknown): PluginBanner[] => { if (!isRecord(response) || !isRecord(response.data) || !Array.isArray(response.data.banners)) return [] return response.data.banners - .map(parseRecommendBanner) - .filter((banner): banner is BannerRecommend => Boolean(banner)) + .map(parsePluginBanner) + .filter((banner): banner is PluginBanner => Boolean(banner)) .sort((a, b) => a.sort - b.sort) - .slice(0, MAX_TRENDING_PAGES) } -export const fetchPluginRecommendBanners = async (language: string): Promise => { +export const fetchPluginBanners = async (language: string): Promise => { const response = await marketplaceClient.banners.list({ query: { page: 'plugins', @@ -123,5 +278,5 @@ export const fetchPluginRecommendBanners = async (language: string): Promise diff --git a/web/app/components/plugins/marketplace/home/home-trending-indicator.module.css b/web/app/components/plugins/marketplace/home/home-trending-indicator.module.css deleted file mode 100644 index e459f77d1f7..00000000000 --- a/web/app/components/plugins/marketplace/home/home-trending-indicator.module.css +++ /dev/null @@ -1,33 +0,0 @@ -@property --trending-progress-angle { - syntax: ''; - inherits: false; - initial-value: 0deg; -} - -.progress { - --trending-progress-angle: 0deg; - - position: absolute; - inset: 0; - border-radius: 7px; - background: conic-gradient( - from 0deg, - var(--color-text-primary) var(--trending-progress-angle), - transparent var(--trending-progress-angle) - ); - animation-name: progress; - animation-timing-function: linear; - animation-fill-mode: forwards; -} - -@keyframes progress { - to { - --trending-progress-angle: 360deg; - } -} - -@media (prefers-reduced-motion: reduce) { - .progress { - animation: none; - } -} diff --git a/web/app/components/plugins/marketplace/home/home-trending.module.css b/web/app/components/plugins/marketplace/home/home-trending.module.css new file mode 100644 index 00000000000..417a903e70e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending.module.css @@ -0,0 +1,108 @@ +.wrapper { + padding-bottom: 30px; +} + +.copy { + flex: none; + width: 36.9167%; + height: 200px; +} + +.recommendVisual { + flex: 1; + min-width: 0; + container-type: inline-size; +} + +.recommendCards { + display: flex; + justify-content: space-between; + gap: 12px; + overflow: hidden; + padding: 42px 36px; +} + +.navigation { + top: 208px; + width: 100%; +} + +.contentTrack { + transition: transform 400ms ease-out; +} + +.card { + flex: 1 1 161px; + width: auto; + min-width: 161px; + max-width: 210px; + box-shadow: 0 8px 7.2px -6px rgb(0 0 0 / 19%); + scroll-snap-align: start; +} + +@container (max-width: 751px) { + .recommendCards > .card:nth-child(n + 4) { + display: none; + } +} + +@container (max-width: 578px) { + .recommendCards > .card:nth-child(n + 3) { + display: none; + } +} + +@container (max-width: 405px) { + .recommendCards { + justify-content: flex-start; + overflow-x: auto; + scroll-snap-type: x proximity; + scrollbar-width: none; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + } + + .recommendCards::-webkit-scrollbar { + display: none; + } + + .recommendCards > .card:nth-child(n) { + display: flex; + flex: 0 0 161px; + } +} + +.updatesArt { + width: 33.3333%; + max-width: 400px; +} + +.updatesDescription { + display: -webkit-box; + max-height: 40px; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +@media (prefers-reduced-motion: reduce) { + .contentTrack { + transition-duration: 0ms; + } +} + +@media (min-width: 1232px) { + .marketplaceCopy { + width: 443px; + } + + .updatesArt { + width: 400px; + } +} + +@media (min-width: 1260px) { + .embeddedCopy { + width: 431px; + } +} diff --git a/web/app/components/plugins/marketplace/home/home-trending.tsx b/web/app/components/plugins/marketplace/home/home-trending.tsx index 9a231d59068..ab7d6509b49 100644 --- a/web/app/components/plugins/marketplace/home/home-trending.tsx +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -1,181 +1,83 @@ 'use client' -import type { FocusEvent } from 'react' -import type { BannerRecommend, BannerRecommendCard } from './banners' +import type { RefObject } from 'react' +import type { + BannerAd, + BannerBlog, + BannerEvent, + BannerRecommend, + BannerRecommendCard, + PluginBanner, +} from './banners' import { cn } from '@langgenius/dify-ui/cn' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from '#i18n' -import { Carousel, useCarousel } from '@/app/components/base/carousel' +import Partner from '@/app/components/plugins/base/badges/partner' +import Verified from '@/app/components/plugins/base/badges/verified' import { MARKETPLACE_API_PREFIX } from '@/config' import Link from '@/next/link' import background from './assets/background.jpg' -import styles from './home-trending-indicator.module.css' +import difyUpdatesArt from './assets/dify-updates-art.png' +import styles from './home-trending.module.css' const AUTOPLAY_DELAY = 5000 +const PAGINATION_DOT_SIZE = 6 +const PAGINATION_ACTIVE_WIDTH = 40 +const PAGINATION_GAP = 8 +const PAGINATION_STEP = PAGINATION_DOT_SIZE + PAGINATION_GAP +const PAGINATION_ACTIVE_SHIFT = PAGINATION_ACTIVE_WIDTH - PAGINATION_DOT_SIZE -type TrendingIndicatorProps = { - index: number - label: string - isCurrent: boolean - isNextSlide: boolean - isPaused: boolean - onClick: () => void -} +const getPaginationItemOffset = (index: number, selectedIndex: number) => + index * PAGINATION_STEP + (index > selectedIndex ? PAGINATION_ACTIVE_SHIFT : 0) -const TrendingIndicator = ({ - index, - label, - isCurrent, - isNextSlide, - isPaused, - onClick, -}: TrendingIndicatorProps) => { - return ( - - ) -} +type AutoplayPauseReason = 'focus' | 'hover' | 'reduced-motion' | 'user' | 'visibility' -type TrendingCopyProps = { - banners: BannerRecommend[] - isMarketplacePlatform: boolean -} - -const TrendingCopy = ({ - banners, +function TrendingCopy({ + banner, isMarketplacePlatform, -}: TrendingCopyProps) => { +}: { + banner: BannerRecommend + isMarketplacePlatform: boolean +}) { const { t } = useTranslation('plugin') - const { api, selectedIndex } = useCarousel() - const [isPlaying, setIsPlaying] = useState(false) - const shouldResumeAfterFocusRef = useRef(false) - const nextIndex = (selectedIndex + 1) % banners.length - - const pauseRotationForFocus = () => { - const autoplay = api?.plugins().autoplay - if (!autoplay?.isPlaying()) - return - - shouldResumeAfterFocusRef.current = true - autoplay.stop() - } - - const resumeRotationAfterFocus = (event: FocusEvent) => { - if (event.currentTarget.contains(event.relatedTarget)) - return - if (!shouldResumeAfterFocusRef.current) - return - - shouldResumeAfterFocusRef.current = false - api?.plugins().autoplay?.play() - } - - useEffect(() => { - if (!api) - return - - const handleAutoplayPlay = () => setIsPlaying(true) - const handleAutoplayStop = () => setIsPlaying(false) - - // oxlint-disable-next-line eslint-react/set-state-in-effect -- Embla owns this external playback state. - setIsPlaying(api.plugins().autoplay?.isPlaying() ?? false) - api.on('autoplay:play', handleAutoplayPlay) - api.on('autoplay:stop', handleAutoplayStop) - - return () => { - api.off('autoplay:play', handleAutoplayPlay) - api.off('autoplay:stop', handleAutoplayStop) - } - }, [api]) + const heading = banner.content.heading || t(($) => $['marketplace.home.trendingTitle']) + const description = + banner.content.description || + banner.content.subheadings?.join(' · ') || + t(($) => $['marketplace.home.trendingDescription']) return (
-
-

- {t(($) => $['marketplace.home.trendingEyebrow'])} +

+

+ {banner.title}

-

+ {heading}

-

- {t(($) => $['marketplace.home.trendingDescription'])} +

+ {description}

- -
$['marketplace.home.trendingPaginationLabel'])} - className="flex shrink-0 items-center py-1 pr-10" - onFocusCapture={pauseRotationForFocus} - onBlurCapture={resumeRotationAfterFocus} - > -
- {banners.map((banner, index) => ( - api?.scrollTo(index)} - /> - ))} -
-
) } const getMarketplaceAssetURL = (path?: string) => { - if (!path) - return '' - if (/^https?:\/\//.test(path)) - return path + if (!path) return '' + if (/^https?:\/\//.test(path) || path.startsWith('/_next/')) return path try { const apiURL = new URL(MARKETPLACE_API_PREFIX) - if (path.startsWith('/api/')) - return `${apiURL.origin}${path}` + if (path.startsWith('/api/')) return `${apiURL.origin}${path}` return `${MARKETPLACE_API_PREFIX.replace(/\/$/, '')}/${path.replace(/^\//, '')}` - } - catch { + } catch { return path } } @@ -187,42 +89,37 @@ const getLocalCardHref = (card: BannerRecommendCard) => { return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}` } - if (card.item_type === 'template') - return `/templates?tid=${encodeURIComponent(card.item_id)}` + if (card.item_type === 'template') return `/templates?tid=${encodeURIComponent(card.item_id)}` return '/' } -const getCardHref = ( - card: BannerRecommendCard, - isMarketplacePlatform: boolean, -) => { - if (!isMarketplacePlatform && card.link) - return card.link +const getCardHref = (card: BannerRecommendCard, isMarketplacePlatform: boolean) => { + if (!isMarketplacePlatform && card.link) return card.link return getLocalCardHref(card) } const getCardCreator = (card: BannerRecommendCard) => { - if (card.item_type !== 'plugin') - return '' + if (card.creator) return card.creator + if (card.item_type !== 'plugin') return '' return card.item_id.split('/')[0] || '' } -type TrendingCardProps = { - card: BannerRecommendCard - isMarketplacePlatform: boolean -} - -const TrendingCard = ({ +function TrendingCard({ card, isMarketplacePlatform, -}: TrendingCardProps) => { +}: { + card: BannerRecommendCard + isMarketplacePlatform: boolean +}) { const { t } = useTranslation('plugin') const iconURL = getMarketplaceAssetURL(card.icon_url) const creator = getCardCreator(card) const href = getCardHref(card, isMarketplacePlatform) const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href) + const isPartner = card.badges?.includes('partner') + const isVerified = card.badges?.includes('verified') return (
- {!iconURL && card.icon - ? {card.icon} - : null} - {!iconURL && !card.icon - ?
-

- {card.display_name} -

- {creator - ? ( -

+

+
+
+

+ {card.display_name} +

+ {(isPartner || isVerified) && ( +
+ {isPartner && ( + $['marketplace.partnerTip'])} /> + )} + {isVerified && ( + $['marketplace.verifiedTip'])} /> + )} +
+ )} +
+ {creator && ( +

{t(($) => $['marketplace.home.trendingByCreator'], { creator })}

- ) - : null} - -
+ + {t(($) => $['marketplace.home.trendingView'])} + +
) } -type TrendingSlideProps = { - banner: BannerRecommend - isMarketplacePlatform: boolean -} - -const TrendingSlide = ({ +function TrendingRecommendationSlide({ banner, isMarketplacePlatform, -}: TrendingSlideProps) => { +}: { + banner: BannerRecommend + isMarketplacePlatform: boolean +}) { return ( -
- -
+
+ +
+ +
-
- {banner.content.cards.map(card => ( - - ))} +
+ {banner.content.cards.map((card) => ( + + ))} +
) } -type HomeTrendingProps = { - banners: BannerRecommend[] - isMarketplacePlatform: boolean +function BlogBannerSlide({ banner }: { banner: BannerBlog }) { + const opensInNewTab = /^https?:\/\//.test(banner.content.link) + + return ( +
+
+
+

+ {banner.title} +

+
+

+ {banner.content.blog_title} +

+
+ {banner.content.subtitle && ( +

+ {banner.content.subtitle} +

+ )} + {banner.content.description && ( +

+ {banner.content.description} +

+ )} + + Read more + + +
+
+
+
+ +
+ ) } -const HomeTrending = ({ +function ImageBannerSlide({ banner }: { banner: BannerEvent | BannerAd }) { + const desktopImage = getMarketplaceAssetURL(banner.content.images.desktop) + const tabletImage = getMarketplaceAssetURL(banner.content.images.tablet) + const mobileImage = getMarketplaceAssetURL(banner.content.images.mobile) + + return ( + + + {mobileImage && } + {tabletImage && } + + + + ) +} + +function HomeBannerSlide({ + banner, + isMarketplacePlatform, +}: { + banner: PluginBanner + isMarketplacePlatform: boolean +}) { + if (banner.style_type === 'blog') return + + if (banner.style_type === 'event' || banner.style_type === 'ad') + return + + return ( + + ) +} + +function TrendingNavigation({ + banners, + selectedIndex, + carouselRootRef, + onSelect, + onNext, +}: { + banners: PluginBanner[] + selectedIndex: number + carouselRootRef: RefObject + onSelect: (index: number) => void + onNext: () => void +}) { + const { t } = useTranslation('plugin') + const progressRef = useRef(null) + const progressAnimationRef = useRef(null) + const pauseReasonsRef = useRef(new Set()) + const [isUserPaused, setIsUserPaused] = useState(false) + const [isReducedMotionPaused, setIsReducedMotionPaused] = useState(false) + const isExplicitlyPaused = isUserPaused || isReducedMotionPaused + const paginationWidth = + PAGINATION_ACTIVE_WIDTH + Math.max(0, banners.length - 1) * PAGINATION_STEP + + const setPauseReason = useCallback((reason: AutoplayPauseReason, shouldPause: boolean) => { + if (shouldPause) pauseReasonsRef.current.add(reason) + else pauseReasonsRef.current.delete(reason) + + const progressAnimation = progressAnimationRef.current + if (!progressAnimation) return + + if (pauseReasonsRef.current.size > 0) progressAnimation.pause() + else progressAnimation.play() + }, []) + + useEffect(() => { + const progressElement = progressRef.current + if (!progressElement?.animate) return + + const progressAnimation = progressElement.animate( + [{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }], + { + duration: AUTOPLAY_DELAY, + easing: 'linear', + fill: 'forwards', + }, + ) + progressAnimationRef.current = progressAnimation + + if (pauseReasonsRef.current.size > 0) progressAnimation.pause() + progressAnimation.onfinish = onNext + + return () => { + progressAnimation.onfinish = null + progressAnimation.cancel() + if (progressAnimationRef.current === progressAnimation) progressAnimationRef.current = null + } + }, [onNext, selectedIndex]) + + useEffect(() => { + const carouselRoot = carouselRootRef.current + if (!carouselRoot) return + + const handleMouseEnter = () => setPauseReason('hover', true) + const handleMouseLeave = () => setPauseReason('hover', false) + const handleFocusIn = () => setPauseReason('focus', true) + const handleFocusOut = (event: FocusEvent) => { + if (carouselRoot.contains(event.relatedTarget as Node | null)) return + setPauseReason('focus', false) + } + const handleVisibilityChange = () => + setPauseReason('visibility', document.visibilityState === 'hidden') + + carouselRoot.addEventListener('mouseenter', handleMouseEnter) + carouselRoot.addEventListener('mouseleave', handleMouseLeave) + carouselRoot.addEventListener('focusin', handleFocusIn) + carouselRoot.addEventListener('focusout', handleFocusOut) + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + carouselRoot.removeEventListener('mouseenter', handleMouseEnter) + carouselRoot.removeEventListener('mouseleave', handleMouseLeave) + carouselRoot.removeEventListener('focusin', handleFocusIn) + carouselRoot.removeEventListener('focusout', handleFocusOut) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [carouselRootRef, setPauseReason]) + + useEffect(() => { + const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)') + const syncReducedMotion = () => { + // oxlint-disable-next-line eslint-react/set-state-in-effect -- This state mirrors an external media query. + setIsReducedMotionPaused(reducedMotionQuery.matches) + setPauseReason('reduced-motion', reducedMotionQuery.matches) + } + + syncReducedMotion() + reducedMotionQuery.addEventListener('change', syncReducedMotion) + + return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion) + }, [setPauseReason]) + + const toggleAutoplay = () => { + if (isExplicitlyPaused) { + setIsUserPaused(false) + setIsReducedMotionPaused(false) + setPauseReason('user', false) + setPauseReason('reduced-motion', false) + return + } + + setIsUserPaused(true) + setPauseReason('user', true) + } + + return ( +
$['marketplace.home.trendingPaginationLabel'])} + className={cn( + styles.navigation, + 'absolute right-0 z-10 flex h-[22px] items-center gap-2 px-5 py-2', + )} + > +
+ + + + {banners.map((banner, index) => { + const isCurrent = index === selectedIndex + + return ( +
+
+ +
+ ) +} + +function HomeTrending({ banners, isMarketplacePlatform, -}: HomeTrendingProps) => { +}: { + banners: PluginBanner[] + isMarketplacePlatform: boolean +}) { const { t } = useTranslation('plugin') - const [carouselPlugins] = useState(() => [ - Carousel.Plugin.Autoplay({ - delay: AUTOPLAY_DELAY, - stopOnFocusIn: true, - stopOnInteraction: false, - stopOnMouseEnter: true, - breakpoints: { - '(prefers-reduced-motion: reduce)': { active: false }, - }, - }), - ]) + const carouselRootRef = useRef(null) + const [selectedIndex, setSelectedIndex] = useState(0) + const selectSlide = useCallback((index: number) => setSelectedIndex(index), []) + const selectNextSlide = useCallback( + () => setSelectedIndex((currentIndex) => (currentIndex + 1) % banners.length), + [banners.length], + ) - if (banners.length === 0) - return null + if (banners.length === 0) return null return (
$['marketplace.home.trendingTitle'])} className={cn( 'shrink-0 bg-background-default pb-6', - isMarketplacePlatform - ? 'px-4 min-[1232px]:px-0' - : 'px-4 md:px-9', + isMarketplacePlatform ? 'px-4 min-[1232px]:px-0' : 'px-4 md:px-9', )} >
- - )} +
$['marketplace.home.trendingTitle'])} - className={cn( - 'ml-auto w-full rounded-xl', - isMarketplacePlatform - ? 'min-[1232px]:w-[757px]' - : 'min-[1260px]:w-[757px]', - )} + className="relative h-[200px] w-full rounded-2xl" > - - {banners.map(banner => ( - - - - ))} - - + +
+
+ {banners.map((banner, index) => { + const isActive = index === selectedIndex + + return ( +
+ +
+ ) + })} +
+
+
) diff --git a/web/app/components/plugins/marketplace/home/index.tsx b/web/app/components/plugins/marketplace/home/index.tsx index 26d7b562848..5a35d95d327 100644 --- a/web/app/components/plugins/marketplace/home/index.tsx +++ b/web/app/components/plugins/marketplace/home/index.tsx @@ -1,5 +1,4 @@ -import type { BannerRecommend } from './banners' -import { cn } from '@langgenius/dify-ui/cn' +import type { PluginBanner } from './banners' import ListWrapper from '../list/list-wrapper' import HomeCatalogNavigation from './home-catalog-navigation' import HomeCatalogTabs from './home-catalog-tabs' @@ -11,7 +10,7 @@ import HomeTrending from './home-trending' type MarketplaceHomeProps = { actions?: React.ReactNode - banners: BannerRecommend[] + banners: PluginBanner[] brandName?: React.ReactNode isMarketplacePlatform: boolean linkToMarketplaceDetail: boolean @@ -37,11 +36,12 @@ const MarketplaceHome = ({
-