mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
feat: api nodes icon
This commit is contained in:
@@ -12,6 +12,9 @@
|
||||
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
|
||||
}
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@theme {
|
||||
|
||||
86
src/components/common/BadgePill.test.ts
Normal file
86
src/components/common/BadgePill.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import BadgePill from './BadgePill.vue'
|
||||
|
||||
describe('BadgePill', () => {
|
||||
it('renders text content', () => {
|
||||
const wrapper = mount(BadgePill, {
|
||||
props: { text: 'Test Badge' }
|
||||
})
|
||||
expect(wrapper.text()).toBe('Test Badge')
|
||||
})
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
const wrapper = mount(BadgePill, {
|
||||
props: { icon: 'icon-[comfy--credits]', text: 'Credits' }
|
||||
})
|
||||
expect(wrapper.find('i.icon-\\[comfy--credits\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('applies iconClass to icon', () => {
|
||||
const wrapper = mount(BadgePill, {
|
||||
props: {
|
||||
icon: 'icon-[comfy--credits]',
|
||||
iconClass: 'text-amber-400'
|
||||
}
|
||||
})
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain('text-amber-400')
|
||||
})
|
||||
|
||||
it('uses default border color when no borderStyle', () => {
|
||||
const wrapper = mount(BadgePill, {
|
||||
props: { text: 'Default' }
|
||||
})
|
||||
expect(wrapper.attributes('style')).toContain('border-color: #525252')
|
||||
})
|
||||
|
||||
it('applies solid border color when borderStyle is a color', () => {
|
||||
const wrapper = mount(BadgePill, {
|
||||
props: { text: 'Colored', borderStyle: '#f59e0b' }
|
||||
})
|
||||
expect(wrapper.attributes('style')).toContain('border-color: #f59e0b')
|
||||
})
|
||||
|
||||
it('applies gradient border when borderStyle contains linear-gradient', () => {
|
||||
const gradient = 'linear-gradient(90deg, #3186FF, #FABC12)'
|
||||
const wrapper = mount(BadgePill, {
|
||||
props: { text: 'Gradient', borderStyle: gradient }
|
||||
})
|
||||
const style = wrapper.attributes('style')
|
||||
expect(style).toContain('border-color: transparent')
|
||||
expect(style).toContain('background-image')
|
||||
})
|
||||
|
||||
it('applies filled style with background and text color', () => {
|
||||
const wrapper = mount(BadgePill, {
|
||||
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
|
||||
})
|
||||
const style = wrapper.attributes('style')
|
||||
expect(style).toContain('border-color: #f59e0b')
|
||||
expect(style).toContain('background-color: #f59e0b33')
|
||||
expect(style).toContain('color: #f59e0b')
|
||||
})
|
||||
|
||||
it('has white text when not filled', () => {
|
||||
const wrapper = mount(BadgePill, {
|
||||
props: { text: 'Not Filled', borderStyle: '#f59e0b' }
|
||||
})
|
||||
expect(wrapper.classes()).toContain('text-white')
|
||||
})
|
||||
|
||||
it('does not have white text class when filled', () => {
|
||||
const wrapper = mount(BadgePill, {
|
||||
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
|
||||
})
|
||||
expect(wrapper.classes()).not.toContain('text-white')
|
||||
})
|
||||
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(BadgePill, {
|
||||
slots: { default: 'Slot Content' }
|
||||
})
|
||||
expect(wrapper.text()).toBe('Slot Content')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<span
|
||||
class="flex items-center gap-1 rounded border border-neutral-600 px-1.5 py-0.5 text-xxs text-white"
|
||||
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
|
||||
:class="textColorClass"
|
||||
:style="customStyle"
|
||||
>
|
||||
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />
|
||||
<slot>{{ text }}</slot>
|
||||
@@ -8,11 +10,45 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
const { borderStyle, filled } = defineProps<{
|
||||
text?: string
|
||||
icon?: string
|
||||
iconClass?: string
|
||||
borderStyle?: string
|
||||
filled?: boolean
|
||||
}>()
|
||||
|
||||
const textColorClass = computed(() =>
|
||||
borderStyle && filled ? '' : 'text-white'
|
||||
)
|
||||
|
||||
const customStyle = computed(() => {
|
||||
if (!borderStyle) {
|
||||
return { borderColor: '#525252' }
|
||||
}
|
||||
|
||||
const isGradient = borderStyle.includes('linear-gradient')
|
||||
if (isGradient) {
|
||||
return {
|
||||
borderColor: 'transparent',
|
||||
backgroundImage: `linear-gradient(#1a1a1a, #1a1a1a), ${borderStyle}`,
|
||||
backgroundOrigin: 'border-box',
|
||||
backgroundClip: 'padding-box, border-box'
|
||||
}
|
||||
}
|
||||
|
||||
if (filled) {
|
||||
return {
|
||||
borderColor: borderStyle,
|
||||
backgroundColor: `${borderStyle}33`,
|
||||
color: borderStyle
|
||||
}
|
||||
}
|
||||
|
||||
return { borderColor: borderStyle }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,17 +15,19 @@
|
||||
</h3>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<BadgePill
|
||||
v-show="nodeDef.api_node && creditsLabel"
|
||||
:text="creditsLabel"
|
||||
icon="icon-[comfy--credits]"
|
||||
icon-class="text-amber-400"
|
||||
border-style="#f59e0b"
|
||||
filled
|
||||
/>
|
||||
<BadgePill
|
||||
v-show="nodeDef.api_node && categoryLabel"
|
||||
:text="categoryLabel"
|
||||
icon="icon-[lucide--bar-chart-2]"
|
||||
:icon="getProviderIcon(categoryLabel ?? '')"
|
||||
:border-style="getProviderBorderStyle(categoryLabel ?? '')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -84,6 +86,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
|
||||
import BadgePill from '@/components/common/BadgePill.vue'
|
||||
import { getProviderBorderStyle, getProviderIcon } from '@/utils/categoryUtil'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
DEFAULT_TAB_ID,
|
||||
nodeOrganizationService
|
||||
} from '@/services/nodeOrganizationService'
|
||||
import { getProviderIcon } from '@/utils/categoryUtil'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { TabId } from '@/types/nodeOrganizationTypes'
|
||||
@@ -140,8 +141,7 @@ function getFolderIcon(node: TreeNode): string {
|
||||
firstLeaf?.key?.startsWith('root/api node') &&
|
||||
firstLeaf.key.replace(`${node.key}/`, '') === firstLeaf.label
|
||||
) {
|
||||
const iconKey = node.label?.toLowerCase().replaceAll(/\s+/g, '-') ?? ''
|
||||
return `icon-[comfy--${iconKey}]`
|
||||
return getProviderIcon(node.label ?? '')
|
||||
}
|
||||
return 'icon-[ph--folder-fill]'
|
||||
}
|
||||
|
||||
@@ -305,8 +305,6 @@ interface Props {
|
||||
loadingTier?: CheckoutTierKey | null
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
loadingTier: null
|
||||
|
||||
93
src/utils/categoryUtil.test.ts
Normal file
93
src/utils/categoryUtil.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
generateCategoryId,
|
||||
getCategoryIcon,
|
||||
getProviderBorderStyle,
|
||||
getProviderIcon
|
||||
} from './categoryUtil'
|
||||
|
||||
describe('getCategoryIcon', () => {
|
||||
it('returns mapped icon for known category', () => {
|
||||
expect(getCategoryIcon('all')).toBe('icon-[lucide--list]')
|
||||
expect(getCategoryIcon('image')).toBe('icon-[lucide--image]')
|
||||
expect(getCategoryIcon('video')).toBe('icon-[lucide--film]')
|
||||
})
|
||||
|
||||
it('returns folder icon for unknown category', () => {
|
||||
expect(getCategoryIcon('unknown-category')).toBe('icon-[lucide--folder]')
|
||||
})
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(getCategoryIcon('ALL')).toBe('icon-[lucide--list]')
|
||||
expect(getCategoryIcon('Image')).toBe('icon-[lucide--image]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProviderIcon', () => {
|
||||
it('returns icon class for simple provider name', () => {
|
||||
expect(getProviderIcon('BFL')).toBe('icon-[comfy--bfl]')
|
||||
expect(getProviderIcon('OpenAI')).toBe('icon-[comfy--openai]')
|
||||
})
|
||||
|
||||
it('converts spaces to hyphens', () => {
|
||||
expect(getProviderIcon('Stability AI')).toBe('icon-[comfy--stability-ai]')
|
||||
expect(getProviderIcon('Moonvalley Marey')).toBe(
|
||||
'icon-[comfy--moonvalley-marey]'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles multiple spaces', () => {
|
||||
expect(getProviderIcon('Some Provider Name')).toBe(
|
||||
'icon-[comfy--some-provider-name]'
|
||||
)
|
||||
})
|
||||
|
||||
it('converts to lowercase', () => {
|
||||
expect(getProviderIcon('GEMINI')).toBe('icon-[comfy--gemini]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProviderBorderStyle', () => {
|
||||
it('returns solid color for single-color providers', () => {
|
||||
expect(getProviderBorderStyle('BFL')).toBe('#ffffff')
|
||||
expect(getProviderBorderStyle('OpenAI')).toBe('#B6B6B6')
|
||||
expect(getProviderBorderStyle('Bria')).toBe('#B6B6B6')
|
||||
})
|
||||
|
||||
it('returns gradient for dual-color providers', () => {
|
||||
expect(getProviderBorderStyle('Gemini')).toBe(
|
||||
'linear-gradient(90deg, #3186FF, #FABC12)'
|
||||
)
|
||||
expect(getProviderBorderStyle('Stability AI')).toBe(
|
||||
'linear-gradient(90deg, #9D39FF, #E80000)'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns fallback color for unknown providers', () => {
|
||||
expect(getProviderBorderStyle('Unknown Provider')).toBe('#525252')
|
||||
})
|
||||
|
||||
it('handles provider names with spaces', () => {
|
||||
expect(getProviderBorderStyle('Stability AI')).toBe(
|
||||
'linear-gradient(90deg, #9D39FF, #E80000)'
|
||||
)
|
||||
expect(getProviderBorderStyle('Moonvalley Marey')).toBe('#DAD9C5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateCategoryId', () => {
|
||||
it('generates category ID from group and title', () => {
|
||||
expect(generateCategoryId('Generation', 'Image')).toBe('generation-image')
|
||||
})
|
||||
|
||||
it('converts spaces to hyphens', () => {
|
||||
expect(generateCategoryId('API Nodes', 'Open Source')).toBe(
|
||||
'api-nodes-open-source'
|
||||
)
|
||||
})
|
||||
|
||||
it('converts to lowercase', () => {
|
||||
expect(generateCategoryId('GENERATION', 'VIDEO')).toBe('generation-video')
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,71 @@ export const getCategoryIcon = (categoryId: string): string => {
|
||||
return iconMap[categoryId.toLowerCase()] || 'icon-[lucide--folder]'
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider brand colors extracted from SVG icons.
|
||||
* Each entry can be a single color or [color1, color2] for gradient.
|
||||
*/
|
||||
const PROVIDER_COLORS: Record<string, string | [string, string]> = {
|
||||
bfl: '#ffffff',
|
||||
bria: '#B6B6B6',
|
||||
bytedance: ['#00C8D2', '#325AB4'],
|
||||
gemini: ['#3186FF', '#FABC12'],
|
||||
grok: '#B6B6B6',
|
||||
hitpaw: '#B6B6B6',
|
||||
ideogram: '#B6B6B6',
|
||||
kling: ['#0BF2F9', '#FFF959'],
|
||||
ltxv: '#B6B6B6',
|
||||
luma: ['#004EFF', '#00FFFF'],
|
||||
magnific: ['#EA5A3D', '#F1A64A'],
|
||||
meshy: ['#67B700', '#FA418C'],
|
||||
minimax: ['#E2167E', '#FE603C'],
|
||||
'moonvalley-marey': '#DAD9C5',
|
||||
openai: '#B6B6B6',
|
||||
pixverse: ['#B465E6', '#E8632A'],
|
||||
recraft: '#B6B6B6',
|
||||
rodin: '#F7F7F7',
|
||||
runway: '#B6B6B6',
|
||||
sora: ['#6BB6FE', '#ffffff'],
|
||||
'stability-ai': ['#9D39FF', '#E80000'],
|
||||
tencent: ['#004BE5', '#00B3FE'],
|
||||
topaz: '#B6B6B6',
|
||||
tripo: ['#F6D85A', '#B6B6B6'],
|
||||
veo: ['#4285F4', '#EB4335'],
|
||||
vidu: ['#047FFE', '#40EDD8'],
|
||||
wan: ['#6156EC', '#F4F3FD'],
|
||||
wavespeed: '#B6B6B6'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the icon class for an API node provider (e.g., BFL, OpenAI, Stability AI)
|
||||
* @param providerName - The provider name from the node category
|
||||
* @returns The icon class string (e.g., 'icon-[comfy--bfl]')
|
||||
*/
|
||||
export function getProviderIcon(providerName: string): string {
|
||||
const iconKey = providerName.toLowerCase().replaceAll(/\s+/g, '-')
|
||||
return `icon-[comfy--${iconKey}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the border color(s) for an API node provider badge.
|
||||
* @param providerName - The provider name from the node category
|
||||
* @returns CSS color string or gradient definition
|
||||
*/
|
||||
export function getProviderBorderStyle(providerName: string): string {
|
||||
const iconKey = providerName.toLowerCase().replaceAll(/\s+/g, '-')
|
||||
const colors = PROVIDER_COLORS[iconKey]
|
||||
|
||||
if (!colors) {
|
||||
return '#525252' // neutral-600 fallback
|
||||
}
|
||||
|
||||
if (Array.isArray(colors)) {
|
||||
return `linear-gradient(90deg, ${colors[0]}, ${colors[1]})`
|
||||
}
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique category ID from a category group and title
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user