From 9f7bea37e562e1db3bcb202aa24e68ea120839e0 Mon Sep 17 00:00:00 2001 From: Junyan Chin Date: Tue, 3 Mar 2026 13:09:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20supports=20for=20"Open=20in=20Dif?= =?UTF-8?q?y"=20from=20template=20details=20page=20in=20m=E2=80=A6=20(#328?= =?UTF-8?q?52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...import-from-marketplace-template-modal.tsx | 172 ++++++++++++++++++ web/app/components/apps/index.tsx | 57 +++++- web/contract/marketplace.ts | 13 ++ web/contract/router.ts | 3 +- web/i18n/en-US/app.json | 9 + web/i18n/zh-Hans/app.json | 9 + web/service/marketplace-templates.ts | 48 +++++ 7 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 web/app/components/apps/import-from-marketplace-template-modal.tsx create mode 100644 web/service/marketplace-templates.ts diff --git a/web/app/components/apps/import-from-marketplace-template-modal.tsx b/web/app/components/apps/import-from-marketplace-template-modal.tsx new file mode 100644 index 0000000000..42d705409b --- /dev/null +++ b/web/app/components/apps/import-from-marketplace-template-modal.tsx @@ -0,0 +1,172 @@ +'use client' + +import type { MarketplaceTemplate } from '@/service/marketplace-templates' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import Button from '@/app/components/base/button' +import Modal from '@/app/components/base/modal' +import { useToastContext } from '@/app/components/base/toast' +import { MARKETPLACE_API_PREFIX, MARKETPLACE_URL_PREFIX } from '@/config' +import { + fetchMarketplaceTemplateDSL, + useMarketplaceTemplateDetail, +} from '@/service/marketplace-templates' + +type ImportFromMarketplaceTemplateModalProps = { + templateId: string + onConfirm: (yamlContent: string, template: MarketplaceTemplate) => void + onClose: () => void +} + +const ImportFromMarketplaceTemplateModal = ({ + templateId, + onConfirm, + onClose, +}: ImportFromMarketplaceTemplateModalProps) => { + const { t } = useTranslation() + const { notify } = useToastContext() + + const { data, isLoading, isError } = useMarketplaceTemplateDetail(templateId) + const template = data?.data ?? null + + const [isImporting, setIsImporting] = useState(false) + + const handleConfirm = useCallback(async () => { + if (!template || isImporting) + return + setIsImporting(true) + try { + const yamlContent = await fetchMarketplaceTemplateDSL(templateId) + onConfirm(yamlContent, template) + } + catch { + notify({ + type: 'error', + message: t('marketplace.template.importFailed', { ns: 'app' }), + }) + setIsImporting(false) + } + }, [template, templateId, isImporting, onConfirm, notify, t]) + + const templateUrl = MARKETPLACE_URL_PREFIX + ? `${MARKETPLACE_URL_PREFIX}/templates/${encodeURIComponent(templateId)}` + : undefined + + return ( + + {/* Header */} +
+
+ {t('marketplace.template.modalTitle', { ns: 'app' })} +
+
+
+
+ + {/* Content */} +
+ {isLoading && ( +
+
+ )} + + {isError && !isLoading && ( +
+
+ {t('marketplace.template.fetchFailed', { ns: 'app' })} +
+ +
+ )} + + {template && !isLoading && ( +
+ {/* Template info */} +
+ +
+
+ {template.template_name} +
+
+ {t('marketplace.template.publishedBy', { ns: 'app', publisher: template.publisher_unique_handle })} +
+
+
+ + {/* Overview */} + {template.overview && ( +
+
+ {t('marketplace.template.overview', { ns: 'app' })} +
+
+ {template.overview} +
+
+ )} + + {/* Usage count */} + {template.usage_count !== null && template.usage_count > 0 && ( +
+ {t('marketplace.template.usageCount', { ns: 'app', count: template.usage_count })} +
+ )} + + {/* Marketplace link */} + {templateUrl && ( + + {t('marketplace.template.viewOnMarketplace', { ns: 'app' })} + + )} +
+ )} +
+ + {/* Footer */} + {template && !isLoading && ( +
+ + +
+ )} +
+ ) +} + +export default ImportFromMarketplaceTemplateModal diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 3be8492489..b0420448b7 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -1,7 +1,10 @@ 'use client' import type { CreateAppModalProps } from '../explore/create-app-modal' import type { CurrentTryAppParams } from '@/context/explore-context' -import { useCallback, useState } from 'react' +import type { MarketplaceTemplate } from '@/service/marketplace-templates' +import dynamic from 'next/dynamic' +import { useRouter, useSearchParams } from 'next/navigation' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useEducationInit } from '@/app/education-apply/hooks' import AppListContext from '@/context/app-list-context' @@ -14,8 +17,15 @@ import CreateAppModal from '../explore/create-app-modal' import TryApp from '../explore/try-app' import List from './list' +const ImportFromMarketplaceTemplateModal = dynamic( + () => import('./import-from-marketplace-template-modal'), + { ssr: false }, +) + const Apps = () => { const { t } = useTranslation() + const searchParams = useSearchParams() + const { replace } = useRouter() useDocumentTitle(t('menus.apps', { ns: 'common' })) useEducationInit() @@ -92,6 +102,43 @@ const Apps = () => { }) } + // Marketplace template import via URL param + const marketplaceTemplateId = searchParams.get('template-id') || undefined + const dismissedTemplateIdRef = useRef(undefined) + const showMarketplaceModal = !!marketplaceTemplateId && dismissedTemplateIdRef.current !== marketplaceTemplateId + + const handleCloseMarketplaceModal = useCallback(() => { + dismissedTemplateIdRef.current = marketplaceTemplateId + // Remove template-id from URL without full navigation + const params = new URLSearchParams(searchParams.toString()) + params.delete('template-id') + const newQuery = params.toString() + replace(newQuery ? `/apps?${newQuery}` : '/apps') + }, [searchParams, replace, marketplaceTemplateId]) + + const handleMarketplaceTemplateConfirm = useCallback(async ( + yamlContent: string, + template: MarketplaceTemplate, + ) => { + const payload = { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: yamlContent, + name: template.template_name, + icon: template.icon || undefined, + icon_background: template.icon_background || undefined, + } + await handleImportDSL(payload, { + onSuccess: () => { + handleCloseMarketplaceModal() + onSuccess() + }, + onPending: () => { + handleCloseMarketplaceModal() + setShowDSLConfirmModal(true) + }, + }) + }, [handleImportDSL, onSuccess, handleCloseMarketplaceModal]) + return ( { onHide={() => setIsShowCreateModal(false)} /> )} + + {showMarketplaceModal && marketplaceTemplateId && ( + + )} ) diff --git a/web/contract/marketplace.ts b/web/contract/marketplace.ts index 3573ba5c24..4d5e9d7d57 100644 --- a/web/contract/marketplace.ts +++ b/web/contract/marketplace.ts @@ -1,5 +1,6 @@ import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, PluginsSearchParams } from '@/app/components/plugins/marketplace/types' import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' +import type { MarketplaceTemplate } from '@/service/marketplace-templates' import { type } from '@orpc/contract' import { base } from './base' @@ -54,3 +55,15 @@ export const searchAdvancedContract = base body: Omit }>()) .output(type<{ data: PluginsFromMarketplaceResponse }>()) + +export const templateDetailContract = base + .route({ + path: '/templates/{templateId}', + method: 'GET', + }) + .input(type<{ + params: { + templateId: string + } + }>()) + .output(type<{ data: MarketplaceTemplate }>()) diff --git a/web/contract/router.ts b/web/contract/router.ts index a2db566966..6b53e2e716 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -53,12 +53,13 @@ import { workflowDraftUpdateFeaturesContract, } from './console/workflow' import { workflowCommentContracts } from './console/workflow-comment' -import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace' +import { collectionPluginsContract, collectionsContract, searchAdvancedContract, templateDetailContract } from './marketplace' export const marketplaceRouterContract = { collections: collectionsContract, collectionPlugins: collectionPluginsContract, searchAdvanced: searchAdvancedContract, + templateDetail: templateDetailContract, } export type MarketPlaceInputs = InferContractRouterInputs diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index b300c96136..09a9a2a3bf 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -123,6 +123,15 @@ "importFromDSLUrl": "From URL", "importFromDSLUrlPlaceholder": "Paste DSL link here", "join": "Join the community", + "marketplace.template.categories": "Categories", + "marketplace.template.fetchFailed": "Failed to load template information", + "marketplace.template.importConfirm": "Import Template", + "marketplace.template.importFailed": "Failed to import template", + "marketplace.template.modalTitle": "Import from Marketplace", + "marketplace.template.overview": "Overview", + "marketplace.template.publishedBy": "By {{publisher}}", + "marketplace.template.usageCount": "Used {{count}} times", + "marketplace.template.viewOnMarketplace": "View on Marketplace", "maxActiveRequests": "Max concurrent requests", "maxActiveRequestsPlaceholder": "Enter 0 for unlimited", "maxActiveRequestsTip": "Maximum number of concurrent active requests per app (0 for unlimited)", diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index 90347c1c18..de0454ad1e 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -123,6 +123,15 @@ "importFromDSLUrl": "URL", "importFromDSLUrlPlaceholder": "输入 DSL 文件的 URL", "join": "参与社区", + "marketplace.template.categories": "分类", + "marketplace.template.fetchFailed": "获取模板信息失败", + "marketplace.template.importConfirm": "导入模板", + "marketplace.template.importFailed": "导入模板失败", + "marketplace.template.modalTitle": "从模板市场导入", + "marketplace.template.overview": "概览", + "marketplace.template.publishedBy": "发布者:{{publisher}}", + "marketplace.template.usageCount": "已使用 {{count}} 次", + "marketplace.template.viewOnMarketplace": "在模板市场中查看", "maxActiveRequests": "最大活跃请求数", "maxActiveRequestsPlaceholder": "0 表示不限制", "maxActiveRequestsTip": "当前应用的最大活跃请求数(0 表示不限制)", diff --git a/web/service/marketplace-templates.ts b/web/service/marketplace-templates.ts new file mode 100644 index 0000000000..f429255400 --- /dev/null +++ b/web/service/marketplace-templates.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { marketplaceClient, marketplaceQuery } from '@/service/client' + +export type MarketplaceTemplate = { + id: string + publisher_type: 'individual' | 'organization' + publisher_unique_handle: string + template_name: string + icon: string + icon_background: string + icon_file_key: string + kind: 'classic' | 'sandboxed' + categories: string[] + deps_plugins: string[] + preferred_languages: string[] + overview: string + readme: string + partner_link: string + version: string + status: string + usage_count: number | null + created_at: string + updated_at: string +} + +export const useMarketplaceTemplateDetail = (templateId: string) => { + return useQuery({ + queryKey: marketplaceQuery.templateDetail.queryKey({ + input: { params: { templateId } }, + }), + queryFn: () => marketplaceClient.templateDetail({ params: { templateId } }), + enabled: !!templateId, + }) +} + +export const fetchMarketplaceTemplateDSL = async ( + templateId: string, +): Promise => { + const res = await fetch( + `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(templateId)}/dsl`, + { credentials: 'omit' }, + ) + if (!res.ok) + throw new Error(`Failed to fetch template DSL: ${res.status}`) + + return await res.text() +}