mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 07:00:23 +00:00
feat: add provider logo overlays to workflow template thumbnails
- 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 Supports multiple logos per template with configurable: - Position (Tailwind classes) - Size (sm/md/lg) - Opacity (0-1) Templates can declare logos explicitly. Logo assets are fetched from the workflow_templates repo (templates/logo/*.png). Amp-Thread-ID: https://ampcode.com/threads/T-019c03a1-9f05-70bd-b0a3-2848e54e04af Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -251,6 +251,11 @@
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<LogoOverlay
|
||||
v-if="template.logos?.length"
|
||||
:logos="template.logos"
|
||||
:get-logo-url="getLogoUrl"
|
||||
/>
|
||||
<ProgressSpinner
|
||||
v-if="loadingTemplate === template.name"
|
||||
class="absolute inset-0 z-10 m-auto h-12 w-12"
|
||||
@@ -392,6 +397,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'
|
||||
@@ -472,7 +478,8 @@ const {
|
||||
loadWorkflowTemplate,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription
|
||||
getTemplateDescription,
|
||||
getLogoUrl
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
|
||||
95
src/components/templates/thumbnails/LogoOverlay.test.ts
Normal file
95
src/components/templates/thumbnails/LogoOverlay.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
39
src/components/templates/thumbnails/LogoOverlay.vue
Normal file
39
src/components/templates/thumbnails/LogoOverlay.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="(logo, index) in logos"
|
||||
:key="index"
|
||||
:class="
|
||||
cn('pointer-events-none absolute', logo.position ?? defaultPosition)
|
||||
"
|
||||
:style="{ opacity: logo.opacity ?? 0.9 }"
|
||||
>
|
||||
<img
|
||||
:src="getLogoUrl(logo.provider)"
|
||||
:alt="logo.provider"
|
||||
:class="sizeClasses[logo.size ?? 'md']"
|
||||
class="drop-shadow-md"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
logos,
|
||||
getLogoUrl,
|
||||
defaultPosition = 'bottom-2 right-2'
|
||||
} = defineProps<{
|
||||
logos: LogoInfo[]
|
||||
getLogoUrl: (provider: string) => string
|
||||
defaultPosition?: string
|
||||
}>()
|
||||
|
||||
const sizeClasses: Record<'sm' | 'md' | 'lg', string> = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12'
|
||||
}
|
||||
</script>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WorkflowTemplates[]>([])
|
||||
const englishTemplates = shallowRef<WorkflowTemplates[]>([])
|
||||
const logoIndex = shallowRef<LogoIndex>({})
|
||||
const isLoaded = ref(false)
|
||||
const knownTemplateNames = ref(new Set<string>())
|
||||
|
||||
@@ -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<LogoIndex> {
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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<string, string>
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user