diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index acb39e2e8..81cff0e8c 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -251,6 +251,11 @@ " /> + diff --git a/src/components/templates/thumbnails/LogoOverlay.test.ts b/src/components/templates/thumbnails/LogoOverlay.test.ts new file mode 100644 index 000000000..d49f0c03c --- /dev/null +++ b/src/components/templates/thumbnails/LogoOverlay.test.ts @@ -0,0 +1,95 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue' +import type { LogoInfo } from '@/platform/workflow/templates/types/template' + +describe('LogoOverlay', () => { + const mockGetLogoUrl = (provider: string) => `/logos/${provider}.png` + + const mountOverlay = (logos: LogoInfo[], props = {}) => { + return mount(LogoOverlay, { + props: { + logos, + getLogoUrl: mockGetLogoUrl, + ...props + } + }) + } + + it('renders nothing when logos array is empty', () => { + const wrapper = mountOverlay([]) + expect(wrapper.findAll('img')).toHaveLength(0) + }) + + it('renders a logo with correct src and alt', () => { + const wrapper = mountOverlay([{ provider: 'Google' }]) + const img = wrapper.find('img') + expect(img.attributes('src')).toBe('/logos/Google.png') + expect(img.attributes('alt')).toBe('Google') + }) + + it('renders multiple logos', () => { + const wrapper = mountOverlay([ + { provider: 'Google' }, + { provider: 'OpenAI' }, + { provider: 'Stability' } + ]) + expect(wrapper.findAll('img')).toHaveLength(3) + }) + + it('applies default position when not specified', () => { + const wrapper = mountOverlay([{ provider: 'Google' }]) + const container = wrapper.find('div') + expect(container.classes()).toContain('bottom-2') + expect(container.classes()).toContain('right-2') + }) + + it('applies custom position from logo config', () => { + const wrapper = mountOverlay([ + { provider: 'Google', position: 'top-2 left-2' } + ]) + const container = wrapper.find('div') + expect(container.classes()).toContain('top-2') + expect(container.classes()).toContain('left-2') + }) + + it('applies default medium size class', () => { + const wrapper = mountOverlay([{ provider: 'Google' }]) + const img = wrapper.find('img') + expect(img.classes()).toContain('h-8') + expect(img.classes()).toContain('w-8') + }) + + it('applies small size class', () => { + const wrapper = mountOverlay([{ provider: 'Google', size: 'sm' }]) + const img = wrapper.find('img') + expect(img.classes()).toContain('h-6') + expect(img.classes()).toContain('w-6') + }) + + it('applies large size class', () => { + const wrapper = mountOverlay([{ provider: 'Google', size: 'lg' }]) + const img = wrapper.find('img') + expect(img.classes()).toContain('h-12') + expect(img.classes()).toContain('w-12') + }) + + it('applies default opacity', () => { + const wrapper = mountOverlay([{ provider: 'Google' }]) + const container = wrapper.find('div') + expect(container.attributes('style')).toContain('opacity: 0.9') + }) + + it('applies custom opacity', () => { + const wrapper = mountOverlay([{ provider: 'Google', opacity: 0.5 }]) + const container = wrapper.find('div') + expect(container.attributes('style')).toContain('opacity: 0.5') + }) + + it('images are not draggable', () => { + const wrapper = mountOverlay([{ provider: 'Google' }]) + const img = wrapper.find('img') + expect(img.attributes('draggable')).toBe('false') + }) +}) diff --git a/src/components/templates/thumbnails/LogoOverlay.vue b/src/components/templates/thumbnails/LogoOverlay.vue new file mode 100644 index 000000000..91568613a --- /dev/null +++ b/src/components/templates/thumbnails/LogoOverlay.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/platform/workflow/templates/composables/useTemplateWorkflows.ts b/src/platform/workflow/templates/composables/useTemplateWorkflows.ts index a5ee9c3b9..09c384961 100644 --- a/src/platform/workflow/templates/composables/useTemplateWorkflows.ts +++ b/src/platform/workflow/templates/composables/useTemplateWorkflows.ts @@ -158,7 +158,6 @@ export function useTemplateWorkflows() { */ const fetchTemplateJson = async (id: string, sourceModule: string) => { if (sourceModule === 'default') { - // Default templates provided by frontend are served on this separate endpoint return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json()) } else { return fetch( @@ -167,6 +166,13 @@ export function useTemplateWorkflows() { } } + /** + * Gets logo URL for a provider name + */ + const getLogoUrl = (provider: string): string => { + return workflowTemplatesStore.getLogoUrl(provider) + } + return { // State selectedTemplate, @@ -183,6 +189,7 @@ export function useTemplateWorkflows() { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription, - loadWorkflowTemplate + loadWorkflowTemplate, + getLogoUrl } } diff --git a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts index f96872f79..0c0e087be 100644 --- a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts +++ b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts @@ -9,6 +9,7 @@ import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil' import { normalizeI18nKey } from '@/utils/formatUtil' import type { + LogoIndex, TemplateGroup, TemplateInfo, WorkflowTemplates @@ -31,6 +32,7 @@ export const useWorkflowTemplatesStore = defineStore( const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({}) const coreTemplates = shallowRef([]) const englishTemplates = shallowRef([]) + const logoIndex = shallowRef({}) const isLoaded = ref(false) const knownTemplateNames = ref(new Set()) @@ -475,15 +477,18 @@ export const useWorkflowTemplatesStore = defineStore( customTemplates.value = await api.getWorkflowTemplates() const locale = i18n.global.locale.value - const [coreResult, englishResult] = await Promise.all([ - api.getCoreWorkflowTemplates(locale), - isCloud && locale !== 'en' - ? api.getCoreWorkflowTemplates('en') - : Promise.resolve([]) - ]) + const [coreResult, englishResult, logoIndexResult] = + await Promise.all([ + api.getCoreWorkflowTemplates(locale), + isCloud && locale !== 'en' + ? api.getCoreWorkflowTemplates('en') + : Promise.resolve([]), + fetchLogoIndex() + ]) coreTemplates.value = coreResult englishTemplates.value = englishResult + logoIndex.value = logoIndexResult const coreNames = coreTemplates.value.flatMap((category) => category.templates.map((template) => template.name) @@ -498,6 +503,22 @@ export const useWorkflowTemplatesStore = defineStore( } } + async function fetchLogoIndex(): Promise { + try { + const response = await fetch(api.fileURL('/templates/index_logo.json')) + if (!response.ok) return {} + return await response.json() + } catch { + return {} + } + } + + function getLogoUrl(provider: string): string { + const logoPath = logoIndex.value[provider] + if (!logoPath) return '' + return api.fileURL(`/templates/${logoPath}`) + } + function getEnglishMetadata(templateName: string): { tags?: string[] category?: string @@ -534,7 +555,8 @@ export const useWorkflowTemplatesStore = defineStore( loadWorkflowTemplates, knownTemplateNames, getTemplateByName, - getEnglishMetadata + getEnglishMetadata, + getLogoUrl } } ) diff --git a/src/platform/workflow/templates/types/template.ts b/src/platform/workflow/templates/types/template.ts index 1a9d839f8..5f5f1cc7b 100644 --- a/src/platform/workflow/templates/types/template.ts +++ b/src/platform/workflow/templates/types/template.ts @@ -1,3 +1,16 @@ +export interface LogoInfo { + /** Provider name matching index_logo.json */ + provider: string + /** Tailwind positioning classes */ + position?: string + /** Size: 'sm' (24px), 'md' (32px), 'lg' (48px) */ + size?: 'sm' | 'md' | 'lg' + /** Opacity 0-1, default 0.9 */ + opacity?: number +} + +export type LogoIndex = Record + export interface TemplateInfo { name: string /** @@ -47,6 +60,10 @@ export interface TemplateInfo { * If not specified, the template will be included on all distributions. */ includeOnDistributions?: TemplateIncludeOnDistributionEnum[] + /** + * Logo overlays to display on the template thumbnail. + */ + logos?: LogoInfo[] } export enum TemplateIncludeOnDistributionEnum {