diff --git a/e2e/features/step-definitions/marketplace-performance.steps.ts b/e2e/features/step-definitions/marketplace-performance.steps.ts index 5504d2face6..fbf5af25e22 100644 --- a/e2e/features/step-definitions/marketplace-performance.steps.ts +++ b/e2e/features/step-definitions/marketplace-performance.steps.ts @@ -6,7 +6,8 @@ import { e2eBrowser } from '../../test-env' // Baseline against the frozen marketplace fixture stub: the first card lands // around 2.3-2.6s under Fast 4G + 4x CPU throttling (dominated by the ~630KB // server-rendered HTML), so 4s guards regressions with headroom for slower CI -// runners. +// runners. The stub serves a frozen recommend banner, so the measured first +// screen also includes the trending carousel and its background image. const FIRST_CARD_BUDGET_MS = 4_000 const DOCUMENT_ELEMENT_BUDGET = 2_000 // Hydrating the server-rendered list peaks around ~220ms on shared CI runners diff --git a/e2e/support/marketplace-stub.ts b/e2e/support/marketplace-stub.ts index 7df260198e3..8effe368e35 100644 --- a/e2e/support/marketplace-stub.ts +++ b/e2e/support/marketplace-stub.ts @@ -64,6 +64,33 @@ const frozenCollections = [ makeFrozenCollection('e2e-frozen-popular', 'Frozen Popular'), ] +// A frozen recommend banner keeps the trending carousel (and its decorative +// background image) inside the measured first screen, so the benchmark covers +// the same rendering paths as production instead of an empty banner state. +const frozenBanners = [ + { + id: 'e2e-frozen-banner-trending', + title: 'Trending', + sort: 1, + language: 'en-US', + style_type: 'recommend', + content: { + theme_type: 'hottest', + heading: 'Frozen Trending Plugins', + description: 'Frozen fixture banner for the performance benchmark.', + cards: Array.from({ length: 4 }, (_, index) => ({ + item_type: 'plugin', + item_id: `e2e-fixtures/featured-plugin-${index + 1}`, + display_name: `Featured Plugin ${index + 1}`, + icon_url: `/api/v1/plugins/e2e-fixtures/featured-plugin-${index + 1}/icon`, + creator: 'e2e-fixtures', + link: '', + card_position: index + 1, + })), + }, + }, +] + const frozenCollectionPlugins: Record = { 'e2e-frozen-featured': Array.from({ length: 8 }, (_, index) => makeFrozenPlugin( @@ -92,7 +119,7 @@ const jsonResponse = (data: unknown): StubResponse => ({ }) const resolveStubResponse = (method: string, pathname: string): StubResponse | undefined => { - if (method === 'GET' && pathname === '/banners') return jsonResponse({ banners: [] }) + if (method === 'GET' && pathname === '/banners') return jsonResponse({ banners: frozenBanners }) if (method === 'GET' && pathname === '/collections') return jsonResponse({ collections: frozenCollections }) diff --git a/packages/contracts/marketplace.ts b/packages/contracts/marketplace.ts index d404245cc4b..acd017b3ba7 100644 --- a/packages/contracts/marketplace.ts +++ b/packages/contracts/marketplace.ts @@ -192,6 +192,78 @@ export type TemplateSearchResponse = { export type DownloadPluginResponse = Blob +// Banner payload shapes shared by the standalone marketplace and the embedded +// console. The banners endpoint output stays `unknown` in the contract because +// the delivery format is normalized and runtime-validated in +// `web/app/components/plugins/marketplace/home/banners.ts`. +export type BannerBase = { + id: string + title: string + sort: number + language: string +} + +export type BannerRecommendCard = { + item_type: 'plugin' | 'template' + item_id: string + display_name: string + icon_url?: string + icon?: string + icon_background?: string + creator?: string + badges?: Array<'partner' | 'verified'> + link: string + card_position: number +} + +export type BannerRecommend = BannerBase & { + style_type: 'recommend' + content: { + theme_type: 'newest' | 'hottest' | 'partner' + heading?: string + subheadings?: string[] + description?: string + cards: BannerRecommendCard[] + } +} + +export type BannerBlog = BannerBase & { + style_type: 'blog' + content: { + blog_title: string + subtitle?: string + description?: string + link: string + link_target_type: 'blog' | 'github' + } +} + +export 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 bannerListContract = base .route({ path: '/banners', diff --git a/packages/iconify-collections/assets/public/common/duckduckgo.svg b/packages/iconify-collections/assets/public/common/duckduckgo.svg deleted file mode 100644 index c8e73347985..00000000000 --- a/packages/iconify-collections/assets/public/common/duckduckgo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/packages/iconify-collections/custom-public/icons.json b/packages/iconify-collections/custom-public/icons.json index dbd94802c13..2e6fbd864c8 100644 --- a/packages/iconify-collections/custom-public/icons.json +++ b/packages/iconify-collections/custom-public/icons.json @@ -1,6 +1,6 @@ { "prefix": "custom-public", - "lastModified": 1786630059, + "lastModified": 1786856617, "icons": { "agent-building-blocks": { "body": "" @@ -72,7 +72,6 @@ }, "common-d": { "body": "", - "width": 16, "height": 16 }, "common-diagonal-dividing-line": { @@ -90,14 +89,8 @@ "width": 24, "height": 24 }, - "common-duckduckgo": { - "body": "", - "width": 24, - "height": 24 - }, "common-enter-key": { "body": "", - "width": 16, "height": 16 }, "common-firecrawl": { @@ -142,12 +135,10 @@ }, "common-lock": { "body": "", - "width": 16, "height": 16 }, "common-message-chat-square": { "body": "", - "width": 16, "height": 16 }, "common-multi-path-retrieval": { @@ -177,7 +168,6 @@ }, "common-sparkles-soft-accent": { "body": "", - "width": 16, "height": 16 }, "education-triangle": { diff --git a/packages/iconify-collections/custom-public/info.json b/packages/iconify-collections/custom-public/info.json index 3c70010484a..09bd7c5e800 100644 --- a/packages/iconify-collections/custom-public/info.json +++ b/packages/iconify-collections/custom-public/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-public", "name": "Dify Custom Public", - "total": 152, + "total": 151, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx b/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx index e0b0c412d8b..5f20986e463 100644 --- a/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx @@ -1,5 +1,5 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' import type { ReactNode } from 'react' -import type { PluginBanner } from '../home/banners' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx index 5712fb398be..34e350ac773 100644 --- a/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx @@ -1,5 +1,5 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' import type { ReactNode } from 'react' -import type { PluginBanner } from '../home/banners' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx index 7f1a1093343..d141b88d519 100644 --- a/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx @@ -58,7 +58,9 @@ describe('MarketplaceDetailDialog', () => { const frame = screen.getByTitle('Plugin A · plugin.detailPanel.operation.detail') expect(frame).toHaveAttribute( 'src', - 'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=system&view=modal', + // resolvedTheme maps the "system" preference to the concrete value, so + // the embedded detail page receives light/dark rather than "system". + 'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=light&view=modal', ) expect(document.querySelector('.bg-linear-to-t')).not.toBeInTheDocument() diff --git a/web/app/components/plugins/marketplace/detail-dialog/frame.tsx b/web/app/components/plugins/marketplace/detail-dialog/frame.tsx index b9002ffb1d2..cedc10e7b6d 100644 --- a/web/app/components/plugins/marketplace/detail-dialog/frame.tsx +++ b/web/app/components/plugins/marketplace/detail-dialog/frame.tsx @@ -20,6 +20,11 @@ type MarketplaceDetailDialogFrameProps = { onOpenChange: (open: boolean) => void } +// The iframe load event can be delayed indefinitely on a stalled connection +// (and cross-origin load errors are not observable), so reveal the frame after +// this timeout instead of keeping the skeleton up forever. +const LOADING_REVEAL_TIMEOUT_MS = 15_000 + export default function MarketplaceDetailDialogFrame({ open, src, @@ -32,6 +37,13 @@ export default function MarketplaceDetailDialogFrame({ const closeButtonRef = useRef(null) const [isLoading, setIsLoading] = useState(true) + useEffect(() => { + if (!open) return + + const timeout = window.setTimeout(() => setIsLoading(false), LOADING_REVEAL_TIMEOUT_MS) + return () => window.clearTimeout(timeout) + }, [open, src]) + useEffect(() => { if (!open || !onMessage) return diff --git a/web/app/components/plugins/marketplace/detail-dialog/index.tsx b/web/app/components/plugins/marketplace/detail-dialog/index.tsx index 08f748bc8ef..8f764cdd19c 100644 --- a/web/app/components/plugins/marketplace/detail-dialog/index.tsx +++ b/web/app/components/plugins/marketplace/detail-dialog/index.tsx @@ -26,14 +26,16 @@ function MarketplaceDetailDialog({ }: MarketplaceDetailDialogProps) { const { t } = useTranslation() const locale = useLocale() - const { theme } = useTheme() + // resolvedTheme maps the "system" preference to the concrete light/dark + // value the marketplace page expects. + const { resolvedTheme } = useTheme() const pluginLabel = plugin.label[locale] ?? plugin.label['en-US'] ?? plugin.name const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' }) const detailURL = getPluginLinkInMarketplace(plugin, { installed: String(isInstalled), language: locale, source: globalThis.location?.origin, - theme, + theme: resolvedTheme, view: 'modal', }) diff --git a/web/app/components/plugins/marketplace/embedded.tsx b/web/app/components/plugins/marketplace/embedded.tsx index d1797844c7c..297d6504579 100644 --- a/web/app/components/plugins/marketplace/embedded.tsx +++ b/web/app/components/plugins/marketplace/embedded.tsx @@ -1,10 +1,9 @@ 'use client' -import type { PluginBanner } from './home/banners' +import type { PluginBanner } from '@dify/contracts/marketplace' import type { MarketplaceViewProps } from './view' import { queryOptions, useQuery } from '@tanstack/react-query' import { useLocale } from '@/context/i18n' -import { marketplaceQuery } from '@/service/client' import { useResetMarketplaceSearchModeOnMount } from './atoms' import { fetchPluginBanners } from './home/banners' import { MarketplaceView } from './view' @@ -30,15 +29,12 @@ export function EmbeddedMarketplace({ }: EmbeddedMarketplaceProps) { useResetMarketplaceSearchModeOnMount() const locale = useLocale() - const input = { - query: { - page: 'plugins' as const, - language: locale, - }, - } const { data: banners = [] } = useQuery( queryOptions({ - queryKey: [...marketplaceQuery.banners.list.queryKey({ input }), locale], + // fetchPluginBanners returns normalized PluginBanner[] rather than the + // raw contract response, so it uses its own cache key instead of + // impersonating the generated banners.list contract query. + queryKey: ['marketplace-banners', locale], queryFn: () => fetchPluginBanners(locale), enabled: variant === 'home', initialData: locale === initialLocale ? initialBanners : undefined, 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 index 567f7bba550..52c11de565c 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -1,4 +1,4 @@ -import type { PluginBanner } from '../banners' +import type { PluginBanner } from '@dify/contracts/marketplace' import { act, fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx index 55b8dd0857d..b683cf2ac22 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx @@ -27,16 +27,16 @@ vi.mock('ahooks', async (importOriginal) => { } }) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => - ({ - 'gotoAnything.searching': 'Searching...', - 'marketplace.noPluginFound': 'No integration found', - 'newApp.noTemplateFound': 'No templates found', - })[key] ?? key, - }), -})) +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + + return createReactI18nextMock({ + clearSearch: 'Clear search', + 'gotoAnything.searching': 'Searching...', + 'marketplace.noPluginFound': 'No integration found', + 'newApp.noTemplateFound': 'No templates found', + }) +}) vi.mock('@/service/client', () => ({ marketplaceQuery: { @@ -125,7 +125,7 @@ describe('MarketplaceSearchAutocomplete', () => { expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge') expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('name', 'q') expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('type', 'text') - expect(container.querySelectorAll('button[aria-label="clearSearch"]')).toHaveLength(1) + expect(container.querySelectorAll('button[aria-label="Clear search"]')).toHaveLength(1) expect(container.querySelector('input[type="hidden"]')).toHaveValue('en-US') expect(mockPluginSearch).not.toHaveBeenCalled() }) diff --git a/web/app/components/plugins/marketplace/home/assets/background.jpg b/web/app/components/plugins/marketplace/home/assets/background.jpg deleted file mode 100644 index 38c304faa99..00000000000 Binary files a/web/app/components/plugins/marketplace/home/assets/background.jpg and /dev/null differ diff --git a/web/app/components/plugins/marketplace/home/assets/background.webp b/web/app/components/plugins/marketplace/home/assets/background.webp new file mode 100644 index 00000000000..ff09b6466a6 Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/background.webp differ diff --git a/web/app/components/plugins/marketplace/home/banners.ts b/web/app/components/plugins/marketplace/home/banners.ts index d9cbc0477be..e64da8004bc 100644 --- a/web/app/components/plugins/marketplace/home/banners.ts +++ b/web/app/components/plugins/marketplace/home/banners.ts @@ -1,75 +1,20 @@ +import type { + BannerAd, + BannerBase, + BannerBlog, + BannerEvent, + BannerImageContent, + BannerRecommend, + BannerRecommendCard, + PluginBanner, +} from '@dify/contracts/marketplace' import { marketplaceClient } from '@/service/client' +// The banner types live in @dify/contracts/marketplace so the standalone +// marketplace and the embedded console share one definition; this module owns +// the runtime normalization of the untyped delivery payload. 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 - display_name: string - icon_url?: string - icon?: string - icon_background?: string - creator?: string - badges?: Array<'partner' | 'verified'> - link: string - card_position: number -} - -export type BannerRecommend = BannerBase & { - style_type: 'recommend' - content: { - theme_type: 'newest' | 'hottest' | 'partner' - heading?: string - subheadings?: string[] - description?: string - cards: BannerRecommendCard[] - } -} - -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) } diff --git a/web/app/components/plugins/marketplace/home/home-header.tsx b/web/app/components/plugins/marketplace/home/home-header.tsx index bb065df4d6d..874a7fbcf54 100644 --- a/web/app/components/plugins/marketplace/home/home-header.tsx +++ b/web/app/components/plugins/marketplace/home/home-header.tsx @@ -35,7 +35,9 @@ const HomeHeader = ({ >
diff --git a/web/app/components/plugins/marketplace/home/home-hero.tsx b/web/app/components/plugins/marketplace/home/home-hero.tsx index c7fd3e21e42..05434f1bb68 100644 --- a/web/app/components/plugins/marketplace/home/home-hero.tsx +++ b/web/app/components/plugins/marketplace/home/home-hero.tsx @@ -13,41 +13,12 @@ type HomeHeroProps = { const heroDecorationIconFrameClassName = 'absolute flex size-10 items-center justify-center overflow-hidden rounded-[10px] bg-components-panel-bg shadow-lg' -const DropboxIcon = () => ( - - - -) - +// The DuckDuckGo mark stays a raster export from the design frame; the other +// brand marks come from the shared iconify collections instead of inline SVG. const DuckDuckGoIcon = () => ( ) -const GmailIcon = () => ( - - - - - - - - -) - const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) => { const { t } = useTranslation('plugin') @@ -61,7 +32,7 @@ const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) =>
- + @@ -70,7 +41,7 @@ const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) => - +
diff --git a/web/app/components/plugins/marketplace/home/home-search.tsx b/web/app/components/plugins/marketplace/home/home-search.tsx index 971fea9cc41..ca95c1bc2bc 100644 --- a/web/app/components/plugins/marketplace/home/home-search.tsx +++ b/web/app/components/plugins/marketplace/home/home-search.tsx @@ -7,11 +7,23 @@ import { useTranslation } from '#i18n' import styles from './home-sticky.module.css' import MarketplacePluginSearch from './marketplace-plugin-search' -const HomeSearch = ({ children }: { children?: ReactNode }) => { +type HomeSearchProps = { + children?: ReactNode + /** + * Registers the global Cmd/Ctrl+K focus shortcut. The embedded console + * already binds Mod+K to GotoAnything, so only the standalone marketplace + * should keep this enabled. + */ + enableSearchShortcut?: boolean +} + +const HomeSearch = ({ children, enableSearchShortcut = true }: HomeSearchProps) => { const searchRef = useRef(null) const { t } = useTranslation('plugin') useEffect(() => { + if (!enableSearchShortcut) return + const handleGlobalSearchShortcut = (event: KeyboardEvent) => { if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return @@ -21,7 +33,7 @@ const HomeSearch = ({ children }: { children?: ReactNode }) => { document.addEventListener('keydown', handleGlobalSearchShortcut) return () => document.removeEventListener('keydown', handleGlobalSearchShortcut) - }, []) + }, [enableSearchShortcut]) return (
{ - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - if (isCurrent) return - onSelect(index) - }} className={cn( 'absolute top-0 left-0 z-2 h-1.5 overflow-hidden rounded-full outline-hidden transition-[transform,width,background-color] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] after:absolute after:-inset-2 hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none', isCurrent ? 'bg-transparent' : 'bg-state-base-handle', @@ -564,11 +558,6 @@ function TrendingNavigation({ ], )} onClick={toggleAutoplay} - onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - toggleAutoplay() - }} className="flex size-4 shrink-0 items-center justify-center rounded-full bg-state-base-handle text-text-primary outline-hidden hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" > {isExplicitlyPaused ? ( @@ -625,15 +614,19 @@ function HomeTrending({ className="relative h-[200px] w-full rounded-2xl" data-home-trending-carousel-root > - + {/* A single banner has nothing to rotate through, so skip the + pagination/autoplay controls entirely. */} + {banners.length > 1 && ( + + )}
- {search} + {search} {banners.length > 0 && ( <> )}
$['marketplace.carousel.scrollPrevious'], { ns: 'plugin' })} onClick={scrollPrev} iconClassName="i-ri-arrow-left-s-line" /> $['marketplace.carousel.scrollNext'], { ns: 'plugin' })} onClick={scrollNext} iconClassName="i-ri-arrow-right-s-line" /> 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 05434f6fd15..8f7fd0d02a8 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -10,7 +10,8 @@ import { useMarketplaceMoreClick } from '../atoms' import { buildCarouselPages } from '../utils' import CardWrapper from './card-wrapper' import Carousel from './carousel' -import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE, GRID_CLASS } from './collection-constants' +import { GRID_CLASS } from './collection-constants' +import { useCarouselItemsPerPage } from './use-carousel-items-per-page' const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk' const PARTNERS_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template']) @@ -18,17 +19,6 @@ const COLLECTION_PRELOAD_MARGIN = '320px 0px' const COLLECTION_INTERSECTION_THRESHOLD = 0.01 const MAX_PLACEHOLDER_CARDS = 8 -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 ListWithCollectionProps = { marketplaceCollections: MarketplaceCollection[] marketplaceCollectionPluginsMap: Record @@ -269,16 +259,7 @@ const ListWithCollection = ({ }: ListWithCollectionProps) => { const defaultOnMoreClick = useMarketplaceMoreClick() const handleMoreClick = onCollectionMoreClick ?? defaultOnMoreClick - const [viewportWidth, setViewportWidth] = useState(getViewportWidth) - const itemsPerPage = useMemo(() => getCarouselItemsPerPage(viewportWidth), [viewportWidth]) - - useEffect(() => { - const handleResize = () => setViewportWidth(window.innerWidth) - - window.addEventListener('resize', handleResize) - - return () => window.removeEventListener('resize', handleResize) - }, []) + const itemsPerPage = useCarouselItemsPerPage() return marketplaceCollections .filter((collection) => marketplaceCollectionPluginsMap[collection.name]?.length) diff --git a/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts b/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts new file mode 100644 index 00000000000..88f6e732456 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts @@ -0,0 +1,37 @@ +'use client' + +import { useSyncExternalStore } from 'react' +import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE } from './collection-constants' + +const subscribeToViewport = (onStoreChange: () => void) => { + globalThis.window?.addEventListener('resize', onStoreChange) + + return () => globalThis.window?.removeEventListener('resize', onStoreChange) +} + +const getViewportWidth = () => globalThis.window?.innerWidth ?? CAROUSEL_BREAKPOINTS.xl +const getServerViewportWidth = () => CAROUSEL_BREAKPOINTS.xl + +export function 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 +} + +/** + * Viewport-derived carousel page size. useSyncExternalStore keeps the + * hydration render on the server snapshot (xl) and applies the real viewport + * in a follow-up render, so narrow viewports do not trigger a hydration + * mismatch against the server-rendered markup. + */ +export function useCarouselItemsPerPage() { + const viewportWidth = useSyncExternalStore( + subscribeToViewport, + getViewportWidth, + getServerViewportWidth, + ) + + return getCarouselItemsPerPage(viewportWidth) +} diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts index f4160836db6..66193458656 100644 --- a/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test' -import { filterTemplatesForLocale } from '../template-language' +import { filterTemplatesForLocale, getTemplateCollectionText } from '../template-language' const template = (id: string, preferredLanguages?: string[]) => ({ id, @@ -66,3 +66,22 @@ describe('filterTemplatesForLocale', () => { expect(ids(filterTemplatesForLocale(templates, 'zh_Hans'))).toEqual(['zh']) }) }) + +describe('getTemplateCollectionText', () => { + it('uses the matching collection translation and falls back to English', () => { + const label = { + en_US: 'Featured', + zh_Hans: '精选', + zh_Hant: '精選', + ja_JP: '注目', + } + + expect(getTemplateCollectionText(label, 'zh-Hant')).toBe('精選') + expect(getTemplateCollectionText(label, 'de-DE')).toBe('Featured') + }) + + it('falls back to the first available translation when English is missing', () => { + expect(getTemplateCollectionText({ ja_JP: '注目' }, 'de-DE')).toBe('注目') + expect(getTemplateCollectionText({}, 'de-DE')).toBe('') + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/index.tsx b/web/app/components/plugins/marketplace/templates/index.tsx index 343c2674642..0b5b5e52089 100644 --- a/web/app/components/plugins/marketplace/templates/index.tsx +++ b/web/app/components/plugins/marketplace/templates/index.tsx @@ -5,6 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn' import AccountSection from '@/app/components/main-nav/components/account-section' import { getTranslation } from '@/i18n-config/server' import Link from '@/next/link' +import { redirect } from '@/next/navigation' import { getMarketplaceTemplateCollectionsAndTemplates, searchMarketplaceTemplates, @@ -110,6 +111,55 @@ const PAGE_LINK_CLASS = const PAGE_LINK_DISABLED_CLASS = 'flex h-8 cursor-not-allowed items-center justify-center rounded-lg border-[0.5px] border-divider-subtle px-3 system-sm-medium text-text-quaternary' +type TemplatesHrefOptions = { + category: TemplateCategory + page?: number + query?: string + sortBy?: string + sortOrder?: string + view?: string +} + +function buildTemplatesHref({ + category, + page = 1, + query, + sortBy, + sortOrder, + view, +}: TemplatesHrefOptions) { + const searchParams = new URLSearchParams() + if (query) searchParams.set('q', query) + if (sortBy) searchParams.set('sort_by', sortBy) + if (sortOrder) searchParams.set('sort_order', sortOrder) + if (view) searchParams.set('view', view) + if (page > 1) searchParams.set('page', String(page)) + const queryString = searchParams.toString() + const basePath = category === 'all' ? '/templates' : `/templates/${category}` + return queryString ? `${basePath}?${queryString}` : basePath +} + +// The retry link is a plain anchor on purpose: a full navigation re-runs the +// failed (and uncached) server fetch instead of reusing the router cache. +function LoadErrorState({ + message, + retryHref, + retryLabel, +}: { + message: string + retryHref: string + retryLabel: string +}) { + return ( +
+ {message} + + {retryLabel} + +
+ ) +} + // Server-rendered pagination: plain links keep the search results reachable // beyond the first page without any client-side state. function TemplatePagination({ @@ -137,17 +187,8 @@ function TemplatePagination({ }) { if (pageCount <= 1) return null - const buildHref = (targetPage: number) => { - const searchParams = new URLSearchParams() - if (query) searchParams.set('q', query) - if (sortBy) searchParams.set('sort_by', sortBy) - if (sortOrder) searchParams.set('sort_order', sortOrder) - if (view) searchParams.set('view', view) - if (targetPage > 1) searchParams.set('page', String(targetPage)) - const queryString = searchParams.toString() - const basePath = category === 'all' ? '/templates' : `/templates/${category}` - return queryString ? `${basePath}?${queryString}` : basePath - } + const buildHref = (targetPage: number) => + buildTemplatesHref({ category, page: targetPage, query, sortBy, sortOrder, view }) return (