mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 23:20:07 +00:00
feat: add provider logo overlays to workflow template thumbnails (#8365)
## Summary Add support for overlaying provider logos on workflow template thumbnails at runtime. ## Changes - **What**: - Add `LogoInfo` interface and `logos` field to `TemplateInfo` type - Create `LogoOverlay.vue` component for rendering positioned logos - Fetch logo index from `templates/index_logo.json` in store - Add `getLogoUrl` helper to `useTemplateWorkflows` composable - Integrate `LogoOverlay` into `WorkflowTemplateSelectorDialog` ## Review Focus - Logo positioning uses Tailwind classes (e.g. `absolute bottom-2 right-2`) - Supports multiple logos per template with configurable size/opacity - Gracefully handles missing logos (returns empty string, renders nothing) - Templates must explicitly declare logos - no magic inference from models ## Dependencies Requires separate PR in workflow_templates repo to: 1. Update `index.schema.json` with logos definition 2. Add `logos` field to templates in `index.json` ## Screenshots (if applicable) <img width="869" height="719" alt="image" src="https://github.com/user-attachments/assets/65ed1ee4-fbb4-42c9-95d4-7e37813b3655" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8365-feat-add-provider-logo-overlays-to-workflow-template-thumbnails-2f66d73d365081309236c6b991cb6f7b) by [Unito](https://www.unito.io) --------- Co-authored-by: Subagent 5 <subagent@example.com> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -256,6 +256,11 @@
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<LogoOverlay
|
||||
v-if="template.logos?.length"
|
||||
:logos="template.logos"
|
||||
:get-logo-url="workflowTemplatesStore.getLogoUrl"
|
||||
/>
|
||||
<ProgressSpinner
|
||||
v-if="loadingTemplate === template.name"
|
||||
class="absolute inset-0 z-10 m-auto h-12 w-12"
|
||||
@@ -397,6 +402,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
|
||||
157
src/components/templates/thumbnails/LogoOverlay.test.ts
Normal file
157
src/components/templates/thumbnails/LogoOverlay.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
|
||||
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) =>
|
||||
key === 'templates.logoProviderSeparator' ? ' & ' : key,
|
||||
locale: ref('en')
|
||||
})
|
||||
}))
|
||||
|
||||
type LogoOverlayProps = ComponentProps<typeof LogoOverlay>
|
||||
|
||||
describe('LogoOverlay', () => {
|
||||
function mockGetLogoUrl(provider: string) {
|
||||
return `/logos/${provider}.png`
|
||||
}
|
||||
|
||||
function mountOverlay(
|
||||
logos: LogoInfo[],
|
||||
props: Partial<LogoOverlayProps> = {}
|
||||
) {
|
||||
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 single 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 separate logo entries', () => {
|
||||
const wrapper = mountOverlay([
|
||||
{ provider: 'Google' },
|
||||
{ provider: 'OpenAI' },
|
||||
{ provider: 'Stability' }
|
||||
])
|
||||
expect(wrapper.findAll('img')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('displays provider name as label for single provider', () => {
|
||||
const wrapper = mountOverlay([{ provider: 'Google' }])
|
||||
const span = wrapper.find('span')
|
||||
expect(span.text()).toBe('Google')
|
||||
})
|
||||
|
||||
it('images are not draggable', () => {
|
||||
const wrapper = mountOverlay([{ provider: 'Google' }])
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('draggable')).toBe('false')
|
||||
})
|
||||
|
||||
it('filters out logos with empty URLs', () => {
|
||||
function getLogoUrl(provider: string) {
|
||||
return provider === 'Google' ? '/logos/Google.png' : ''
|
||||
}
|
||||
const wrapper = mount(LogoOverlay, {
|
||||
props: {
|
||||
logos: [{ provider: 'Google' }, { provider: 'Unknown' }],
|
||||
getLogoUrl
|
||||
}
|
||||
})
|
||||
expect(wrapper.findAll('img')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders one logo per unique provider', () => {
|
||||
const wrapper = mountOverlay([
|
||||
{ provider: 'Google' },
|
||||
{ provider: 'OpenAI' }
|
||||
])
|
||||
expect(wrapper.findAll('img')).toHaveLength(2)
|
||||
})
|
||||
|
||||
describe('stacked logos', () => {
|
||||
it('renders multiple providers as stacked overlapping logos', () => {
|
||||
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0].attributes('alt')).toBe('WaveSpeed')
|
||||
expect(images[1].attributes('alt')).toBe('Hunyuan')
|
||||
})
|
||||
|
||||
it('joins provider names with locale-aware conjunction for default label', () => {
|
||||
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
|
||||
const span = wrapper.find('span')
|
||||
expect(span.text()).toBe('WaveSpeed and Hunyuan')
|
||||
})
|
||||
|
||||
it('uses custom label when provided', () => {
|
||||
const wrapper = mountOverlay([
|
||||
{ provider: ['WaveSpeed', 'Hunyuan'], label: 'Custom Label' }
|
||||
])
|
||||
const span = wrapper.find('span')
|
||||
expect(span.text()).toBe('Custom Label')
|
||||
})
|
||||
|
||||
it('applies negative gap for overlap effect', () => {
|
||||
const wrapper = mountOverlay([
|
||||
{ provider: ['WaveSpeed', 'Hunyuan'], gap: -8 }
|
||||
])
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[1].attributes('style')).toContain('margin-left: -8px')
|
||||
})
|
||||
|
||||
it('applies default gap when not specified', () => {
|
||||
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[1].attributes('style')).toContain('margin-left: -6px')
|
||||
})
|
||||
|
||||
it('filters out invalid providers from stacked logos', () => {
|
||||
function getLogoUrl(provider: string) {
|
||||
return provider === 'WaveSpeed' ? '/logos/WaveSpeed.png' : ''
|
||||
}
|
||||
const wrapper = mount(LogoOverlay, {
|
||||
props: {
|
||||
logos: [{ provider: ['WaveSpeed', 'Unknown'] }],
|
||||
getLogoUrl
|
||||
}
|
||||
})
|
||||
expect(wrapper.findAll('img')).toHaveLength(1)
|
||||
expect(wrapper.find('span').text()).toBe('WaveSpeed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('keeps showing remaining providers when one image fails in stacked logos', async () => {
|
||||
const wrapper = mountOverlay([{ provider: ['Google', 'OpenAI'] }])
|
||||
const images = wrapper.findAll('[data-testid="logo-img"]')
|
||||
expect(images).toHaveLength(2)
|
||||
|
||||
await images[0].trigger('error')
|
||||
await nextTick()
|
||||
|
||||
const remainingImages = wrapper.findAll('[data-testid="logo-img"]')
|
||||
expect(remainingImages).toHaveLength(2)
|
||||
expect(remainingImages[1].attributes('alt')).toBe('OpenAI')
|
||||
})
|
||||
})
|
||||
})
|
||||
117
src/components/templates/thumbnails/LogoOverlay.vue
Normal file
117
src/components/templates/thumbnails/LogoOverlay.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="logo in validLogos"
|
||||
:key="logo.key"
|
||||
:class="
|
||||
cn('pointer-events-none absolute z-10', logo.position ?? defaultPosition)
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-show="!hasAllFailed(logo.providers)"
|
||||
data-testid="logo-pill"
|
||||
class="flex items-center gap-1.5 rounded-full bg-black/20 py-1 pr-2"
|
||||
:style="{ opacity: logo.opacity ?? 0.85 }"
|
||||
>
|
||||
<div class="ml-0.5 flex items-center">
|
||||
<img
|
||||
v-for="(provider, providerIndex) in logo.providers"
|
||||
:key="provider"
|
||||
data-testid="logo-img"
|
||||
:src="logo.urls[providerIndex]"
|
||||
:alt="provider"
|
||||
class="h-6 w-6 rounded-full border-2 border-white object-cover"
|
||||
:class="{ relative: providerIndex > 0 }"
|
||||
:style="
|
||||
providerIndex > 0 ? { marginLeft: `${logo.gap ?? -6}px` } : {}
|
||||
"
|
||||
draggable="false"
|
||||
@error="onImageError(provider)"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white">
|
||||
{{ logo.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
function formatProviderList(providers: string[]): string {
|
||||
const localeValue = String(locale.value)
|
||||
try {
|
||||
return new Intl.ListFormat(localeValue, {
|
||||
style: 'long',
|
||||
type: 'conjunction'
|
||||
}).format(providers)
|
||||
} catch {
|
||||
return providers.join(t('templates.logoProviderSeparator'))
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
logos,
|
||||
getLogoUrl,
|
||||
defaultPosition = 'top-2 left-2'
|
||||
} = defineProps<{
|
||||
logos: LogoInfo[]
|
||||
getLogoUrl: (provider: string) => string
|
||||
defaultPosition?: string
|
||||
}>()
|
||||
|
||||
const failedLogos = ref(new Set<string>())
|
||||
|
||||
function onImageError(provider: string) {
|
||||
failedLogos.value = new Set([...failedLogos.value, provider])
|
||||
}
|
||||
|
||||
function hasAllFailed(providers: string[]): boolean {
|
||||
return providers.every((p) => failedLogos.value.has(p))
|
||||
}
|
||||
|
||||
interface ValidatedLogo {
|
||||
key: string
|
||||
providers: string[]
|
||||
urls: string[]
|
||||
label: string
|
||||
position: string | undefined
|
||||
opacity: number | undefined
|
||||
gap: number | undefined
|
||||
}
|
||||
|
||||
const validLogos = computed<ValidatedLogo[]>(() => {
|
||||
const result: ValidatedLogo[] = []
|
||||
|
||||
logos.forEach((logo, index) => {
|
||||
const providers = Array.isArray(logo.provider)
|
||||
? logo.provider
|
||||
: [logo.provider]
|
||||
const urls = providers.map((p) => getLogoUrl(p))
|
||||
const validProviders = providers.filter((_, i) => urls[i])
|
||||
const validUrls = urls.filter((url) => url)
|
||||
|
||||
if (validProviders.length === 0) return
|
||||
|
||||
const providerKey = validProviders.join('-')
|
||||
const layoutKey = `${logo.position ?? ''}-${logo.opacity ?? ''}-${logo.gap ?? ''}`
|
||||
result.push({
|
||||
key: providerKey ? `${providerKey}-${layoutKey}` : `logo-${index}`,
|
||||
providers: validProviders,
|
||||
urls: validUrls,
|
||||
label: logo.label ?? formatProviderList(validProviders),
|
||||
position: logo.position,
|
||||
opacity: logo.opacity,
|
||||
gap: logo.gap
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user