mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-27 10:14:06 +00:00
WIP Template filters
This commit is contained in:
@@ -2,43 +2,123 @@
|
||||
<div class="relative w-full p-4">
|
||||
<div class="h-12 flex items-center gap-4 justify-between">
|
||||
<div class="flex-1 max-w-md">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:placeholder="$t('templateWorkflows.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="200"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'w-full rounded-2xl'
|
||||
<div class="relative w-full">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none z-10"
|
||||
>
|
||||
<i class="pi pi-search text-surface-400"></i>
|
||||
</div>
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:placeholder="$t('templateWorkflows.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="200"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'w-full rounded-md pl-10 pr-10'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="() => {}"
|
||||
/>
|
||||
<div
|
||||
v-if="searchQuery"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 z-10"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-surface-400 hover:text-surface-600 bg-transparent border-none p-0 cursor-pointer"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Model Filter Dropdown -->
|
||||
<div class="relative">
|
||||
<Button
|
||||
ref="modelFilterButton"
|
||||
:label="modelFilterLabel"
|
||||
icon="pi pi-filter"
|
||||
outlined
|
||||
class="rounded-2xl"
|
||||
@click="toggleModelFilter"
|
||||
/>
|
||||
<Popover
|
||||
ref="modelFilterPopover"
|
||||
:pt="{
|
||||
root: { class: 'w-64 max-h-80 overflow-auto' }
|
||||
}"
|
||||
>
|
||||
<div class="p-3">
|
||||
<div class="font-medium mb-2">
|
||||
{{ $t('templateWorkflows.modelFilter') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="model in availableModels"
|
||||
:key="model"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="selectedModels.includes(model)"
|
||||
:binary="true"
|
||||
@change="toggleModel(model)"
|
||||
/>
|
||||
<label class="ml-2 text-sm">{{ model }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
<Select
|
||||
v-model="sortBy"
|
||||
:options="sortOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:placeholder="$t('templateWorkflows.sort')"
|
||||
class="rounded-2xl"
|
||||
:pt="{
|
||||
root: { class: 'min-w-32' }
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mt-2">
|
||||
<small
|
||||
v-if="searchQuery && filteredCount !== null"
|
||||
class="text-color-secondary"
|
||||
>
|
||||
{{ $t('g.resultsCount', { count: filteredCount }) }}
|
||||
</small>
|
||||
<Button
|
||||
v-if="searchQuery"
|
||||
text
|
||||
size="small"
|
||||
icon="pi pi-times"
|
||||
:label="$t('g.clearFilters')"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
<!-- Filter Tags Row -->
|
||||
<div
|
||||
v-if="selectedModels.length > 0"
|
||||
class="flex items-center gap-2 mt-3 overflow-x-auto pb-2"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Tag
|
||||
v-for="model in selectedModels"
|
||||
:key="model"
|
||||
:value="model"
|
||||
severity="secondary"
|
||||
>
|
||||
<template #icon>
|
||||
<button
|
||||
type="button"
|
||||
class="text-surface-400 hover:text-surface-600 bg-transparent border-none p-0 cursor-pointer ml-1"
|
||||
@click="removeModel(model)"
|
||||
>
|
||||
<i class="pi pi-times text-xs"></i>
|
||||
</button>
|
||||
</template>
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -46,19 +126,70 @@
|
||||
<script setup lang="ts">
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Popover from 'primevue/popover'
|
||||
import Select from 'primevue/select'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { filteredCount } = defineProps<{
|
||||
import type { SortOption } from '@/composables/useTemplateFiltering'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { availableModels } = defineProps<{
|
||||
filteredCount?: number | null
|
||||
availableModels: string[]
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
const selectedModels = defineModel<string[]>('selectedModels', {
|
||||
default: () => []
|
||||
})
|
||||
const sortBy = defineModel<SortOption>('sortBy', { default: 'recommended' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
clearFilters: []
|
||||
}>()
|
||||
// emit removed - no longer needed since clearFilters was removed
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = ''
|
||||
emit('clearFilters')
|
||||
const modelFilterButton = ref<HTMLElement>()
|
||||
const modelFilterPopover = ref()
|
||||
|
||||
const sortOptions = [
|
||||
{ label: t('templateWorkflows.sortRecommended'), value: 'recommended' },
|
||||
{ label: t('templateWorkflows.sortAlphabetical'), value: 'alphabetical' },
|
||||
{ label: t('templateWorkflows.sortNewest'), value: 'newest' }
|
||||
]
|
||||
|
||||
const modelFilterLabel = computed(() => {
|
||||
if (selectedModels.value.length === 0) {
|
||||
return t('templateWorkflows.modelFilter')
|
||||
} else if (selectedModels.value.length === 1) {
|
||||
return selectedModels.value[0]
|
||||
} else {
|
||||
return t('templateWorkflows.modelsSelected', {
|
||||
count: selectedModels.value.length
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const toggleModelFilter = (event: Event) => {
|
||||
modelFilterPopover.value.toggle(event)
|
||||
}
|
||||
|
||||
const toggleModel = (model: string) => {
|
||||
const index = selectedModels.value.indexOf(model)
|
||||
if (index > -1) {
|
||||
selectedModels.value.splice(index, 1)
|
||||
} else {
|
||||
selectedModels.value.push(model)
|
||||
}
|
||||
}
|
||||
|
||||
const removeModel = (model: string) => {
|
||||
const index = selectedModels.value.indexOf(model)
|
||||
if (index > -1) {
|
||||
selectedModels.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// clearFilters function removed - no longer used since we removed the clear button
|
||||
</script>
|
||||
|
||||
@@ -62,14 +62,49 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center px-4 py-3">
|
||||
<div class="flex items-center px-4 py-3 relative">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<h3 class="line-clamp-2 text-lg font-normal mb-0" :title="title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Description / Action Buttons Container -->
|
||||
<div class="relative grow">
|
||||
<!-- Description Text -->
|
||||
<p
|
||||
class="line-clamp-2 text-sm text-muted grow transition-opacity duration-200"
|
||||
:class="{ 'opacity-0': isHovered }"
|
||||
:title="description"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons (visible on hover) -->
|
||||
<div
|
||||
v-if="isHovered"
|
||||
class="absolute inset-0 flex items-center gap-2 transition-opacity duration-200"
|
||||
>
|
||||
<Button
|
||||
v-if="template.tutorialUrl"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="flex-1 rounded-lg"
|
||||
@click.stop="openTutorial"
|
||||
>
|
||||
{{ $t('templateWorkflows.tutorial') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
severity="primary"
|
||||
:class="template.tutorialUrl ? 'flex-1' : 'w-full'"
|
||||
class="rounded-lg"
|
||||
@click.stop="$emit('loadWorkflow', template.name)"
|
||||
>
|
||||
{{ $t('templateWorkflows.useTemplate') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,6 +113,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -133,6 +169,12 @@ const title = computed(() =>
|
||||
getTemplateTitle(template, effectiveSourceModule.value)
|
||||
)
|
||||
|
||||
const openTutorial = () => {
|
||||
if (template.tutorialUrl) {
|
||||
window.open(template.tutorialUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
@@ -29,6 +29,15 @@ vi.mock('primevue/selectbutton', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/button', () => ({
|
||||
default: {
|
||||
name: 'Button',
|
||||
template: '<button class="p-button"><slot></slot></button>',
|
||||
props: ['severity', 'outlined', 'size', 'class'],
|
||||
emits: ['click']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
|
||||
default: {
|
||||
template: `
|
||||
@@ -57,8 +66,19 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
|
||||
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-search-bar"></div>',
|
||||
props: ['searchQuery', 'filteredCount'],
|
||||
emits: ['update:searchQuery', 'clearFilters']
|
||||
props: [
|
||||
'searchQuery',
|
||||
'filteredCount',
|
||||
'availableModels',
|
||||
'selectedModels',
|
||||
'sortBy'
|
||||
],
|
||||
emits: [
|
||||
'update:searchQuery',
|
||||
'update:selectedModels',
|
||||
'update:sortBy',
|
||||
'clearFilters'
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -89,8 +109,14 @@ vi.mock('@/composables/useLazyPagination', () => ({
|
||||
vi.mock('@/composables/useTemplateFiltering', () => ({
|
||||
useTemplateFiltering: (templates: any) => ({
|
||||
searchQuery: { value: '' },
|
||||
selectedModels: { value: [] },
|
||||
selectedSubcategory: { value: null },
|
||||
sortBy: { value: 'recommended' },
|
||||
availableSubcategories: { value: [] },
|
||||
availableModels: { value: [] },
|
||||
filteredTemplates: templates,
|
||||
filteredCount: { value: templates.value?.length || 0 }
|
||||
filteredCount: { value: templates.value?.length || 0 },
|
||||
resetFilters: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -127,6 +153,8 @@ describe('TemplateWorkflowView', () => {
|
||||
createTemplate('template-3')
|
||||
],
|
||||
loading: null,
|
||||
availableSubcategories: [],
|
||||
selectedSubcategory: null,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<DataView
|
||||
:value="displayTemplates"
|
||||
:value="finalDisplayTemplates"
|
||||
:layout="layout"
|
||||
data-key="name"
|
||||
:lazy="true"
|
||||
@@ -21,11 +21,37 @@
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<TemplateSearchBar
|
||||
v-model:search-query="searchQuery"
|
||||
:filtered-count="filteredCount"
|
||||
@clear-filters="() => reset()"
|
||||
/>
|
||||
|
||||
<!-- Subcategory Navigation -->
|
||||
<div
|
||||
v-if="availableSubcategories.length > 0"
|
||||
class="mb-4 overflow-x-scroll flex max-w-[1000px]"
|
||||
>
|
||||
<div class="flex gap-2 pb-2">
|
||||
<Button
|
||||
:severity="selectedSubcategory === null ? 'primary' : 'secondary'"
|
||||
:outlined="selectedSubcategory !== null"
|
||||
size="small"
|
||||
class="rounded-2xl whitespace-nowrap"
|
||||
@click="$emit('update:selectedSubcategory', null)"
|
||||
>
|
||||
{{ $t('templateWorkflows.allSubcategories') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-for="subcategory in availableSubcategories"
|
||||
:key="subcategory"
|
||||
:severity="
|
||||
selectedSubcategory === subcategory ? 'primary' : 'secondary'
|
||||
"
|
||||
:outlined="selectedSubcategory !== subcategory"
|
||||
size="small"
|
||||
class="rounded-2xl whitespace-nowrap"
|
||||
@click="$emit('update:selectedSubcategory', subcategory)"
|
||||
>
|
||||
{{ subcategory }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,28 +102,37 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import DataView from 'primevue/dataview'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
|
||||
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
|
||||
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
|
||||
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
|
||||
const {
|
||||
title,
|
||||
sourceModule,
|
||||
categoryTitle,
|
||||
loading,
|
||||
templates,
|
||||
availableSubcategories,
|
||||
selectedSubcategory
|
||||
} = defineProps<{
|
||||
title: string
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: string | null
|
||||
templates: TemplateInfo[]
|
||||
availableSubcategories: string[]
|
||||
selectedSubcategory: string | null
|
||||
}>()
|
||||
|
||||
const layout = useLocalStorage<'grid' | 'list'>(
|
||||
@@ -108,31 +143,28 @@ const layout = useLocalStorage<'grid' | 'list'>(
|
||||
const skeletonCount = 6
|
||||
const loadTrigger = ref<HTMLElement | null>(null)
|
||||
|
||||
const templatesRef = computed(() => templates || [])
|
||||
// Since filtering is now handled at parent level, we just use the templates directly
|
||||
const displayTemplates = computed(() => templates || [])
|
||||
|
||||
const { searchQuery, filteredTemplates, filteredCount } =
|
||||
useTemplateFiltering(templatesRef)
|
||||
// Simplified pagination - only show pagination when we have lots of templates
|
||||
const shouldUsePagination = computed(() => templates.length > 12)
|
||||
|
||||
// When searching, show all results immediately without pagination
|
||||
// When not searching, use lazy pagination
|
||||
const shouldUsePagination = computed(() => !searchQuery.value.trim())
|
||||
|
||||
// Lazy pagination setup using filtered templates
|
||||
// Lazy pagination setup using templates directly
|
||||
const {
|
||||
paginatedItems: paginatedTemplates,
|
||||
isLoading: isLoadingMore,
|
||||
hasMoreItems: hasMoreTemplates,
|
||||
loadNextPage,
|
||||
reset
|
||||
} = useLazyPagination(filteredTemplates, {
|
||||
reset: resetPagination
|
||||
} = useLazyPagination(displayTemplates, {
|
||||
itemsPerPage: 12
|
||||
})
|
||||
|
||||
// Final templates to display
|
||||
const displayTemplates = computed(() => {
|
||||
// Final templates to display with pagination
|
||||
const finalDisplayTemplates = computed(() => {
|
||||
return shouldUsePagination.value
|
||||
? paginatedTemplates.value
|
||||
: filteredTemplates.value
|
||||
: displayTemplates.value
|
||||
})
|
||||
// Intersection observer for auto-loading (only when not searching)
|
||||
useIntersectionObserver(
|
||||
@@ -154,12 +186,13 @@ useIntersectionObserver(
|
||||
}
|
||||
)
|
||||
|
||||
watch([() => templates, searchQuery], () => {
|
||||
reset()
|
||||
watch([() => templates], () => {
|
||||
resetPagination()
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
'update:selectedSubcategory': [value: string | null]
|
||||
}>()
|
||||
|
||||
const onLoadWorkflow = (name: string) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
|
||||
class="flex flex-col h-[83vh] w-[90vw] relative pb-6 px-8 mx-auto"
|
||||
data-testid="template-workflows-content"
|
||||
>
|
||||
<Button
|
||||
@@ -12,12 +12,13 @@
|
||||
@click="toggleSideNav"
|
||||
/>
|
||||
<Divider
|
||||
class="m-0 [&::before]:border-surface-border/70 [&::before]:border-t-2"
|
||||
class="my-0 w-[90vw] -mx-8 relative [&::before]:border-surface-border/70 [&::before]:border-t-2"
|
||||
/>
|
||||
|
||||
<div class="flex flex-1 relative overflow-hidden">
|
||||
<aside
|
||||
v-if="isSideNavOpen"
|
||||
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
|
||||
class="absolute translate-x-0 top-0 left-0 h-full w-60 shadow-md z-5 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
<ProgressSpinner
|
||||
v-if="!isTemplatesLoaded || !isReady"
|
||||
@@ -25,27 +26,64 @@
|
||||
/>
|
||||
<TemplateWorkflowsSideNav
|
||||
:tabs="allTemplateGroups"
|
||||
:selected-tab="selectedTemplate"
|
||||
@update:selected-tab="handleTabSelection"
|
||||
:selected-subcategory="selectedSubcategory"
|
||||
:selected-view="selectedView"
|
||||
@update:selected-subcategory="handleSubcategory"
|
||||
@update:selected-view="handleViewSelection"
|
||||
/>
|
||||
</aside>
|
||||
<div
|
||||
class="flex-1 transition-all duration-300"
|
||||
:class="{
|
||||
'pl-80': isSideNavOpen || !isSmallScreen,
|
||||
'pl-60': isSideNavOpen || !isSmallScreen,
|
||||
'pl-8': !isSideNavOpen && isSmallScreen
|
||||
}"
|
||||
>
|
||||
<TemplateWorkflowView
|
||||
v-if="isReady && selectedTemplate"
|
||||
class="px-12 py-4"
|
||||
:title="selectedTemplate.title"
|
||||
:source-module="selectedTemplate.moduleName"
|
||||
:templates="selectedTemplate.templates"
|
||||
:loading="loadingTemplateId"
|
||||
:category-title="selectedTemplate.title"
|
||||
@load-workflow="handleLoadWorkflow"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
isReady &&
|
||||
(selectedView === 'all' ||
|
||||
selectedView === 'recent' ||
|
||||
selectedSubcategory)
|
||||
"
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
<div class="px-8 sm:px-12 py-4 border-b border-surface-border/20">
|
||||
<TemplateSearchBar
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:selected-models="selectedModels"
|
||||
v-model:sort-by="sortBy"
|
||||
:filtered-count="filteredCount"
|
||||
:available-models="availableModels"
|
||||
@clear-filters="resetFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TemplateWorkflowView
|
||||
class="px-8 sm:px-12 flex-1"
|
||||
:title="
|
||||
selectedSubcategory
|
||||
? selectedSubcategory.label
|
||||
: selectedView === 'all'
|
||||
? $t('templateWorkflows.view.allTemplates', 'All Templates')
|
||||
: selectedTemplate?.title || ''
|
||||
"
|
||||
:source-module="selectedTemplate?.moduleName || 'all'"
|
||||
:templates="filteredTemplates"
|
||||
:available-subcategories="availableSubcategories"
|
||||
:selected-subcategory="filterSubcategory"
|
||||
:loading="loadingTemplateId"
|
||||
:category-title="
|
||||
selectedSubcategory
|
||||
? selectedSubcategory.label
|
||||
: selectedView === 'all'
|
||||
? $t('templateWorkflows.view.allTemplates', 'All Templates')
|
||||
: selectedTemplate?.title || ''
|
||||
"
|
||||
@load-workflow="handleLoadWorkflow"
|
||||
@update:selected-subcategory="filterSubcategory = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,13 +94,15 @@ import { useAsyncState } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
|
||||
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
|
||||
import type { TemplateSubcategory } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const {
|
||||
isSmallScreen,
|
||||
@@ -76,34 +116,78 @@ const {
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
loadWorkflowTemplate
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const { isReady } = useAsyncState(loadTemplates, null)
|
||||
|
||||
// State for subcategory selection
|
||||
const selectedSubcategory = ref<TemplateSubcategory | null>(null)
|
||||
|
||||
// State for view selection (all vs recent)
|
||||
const selectedView = ref<'all' | 'recent'>('all')
|
||||
|
||||
// Template filtering for the top-level search
|
||||
const templatesRef = computed(() => {
|
||||
// If a subcategory is selected, use all templates from that subcategory
|
||||
if (selectedSubcategory.value) {
|
||||
return selectedSubcategory.value.modules.flatMap(
|
||||
(module) => module.templates
|
||||
)
|
||||
}
|
||||
|
||||
// If "All Templates" view is selected and no subcategory, show all templates across all groups
|
||||
if (selectedView.value === 'all') {
|
||||
return allTemplateGroups.value.flatMap((group) =>
|
||||
group.subcategories.flatMap((subcategory) =>
|
||||
subcategory.modules.flatMap((module) => module.templates)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise, use the selected template's templates (for recent view or fallback)
|
||||
return selectedTemplate.value?.templates || []
|
||||
})
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
selectedModels,
|
||||
selectedSubcategory: filterSubcategory,
|
||||
sortBy,
|
||||
availableSubcategories,
|
||||
availableModels,
|
||||
filteredTemplates,
|
||||
filteredCount,
|
||||
resetFilters
|
||||
} = useTemplateFiltering(templatesRef)
|
||||
|
||||
watch(
|
||||
isReady,
|
||||
() => {
|
||||
if (isReady.value) {
|
||||
selectFirstTemplateCategory()
|
||||
// Start with "All Templates" view by default instead of selecting first subcategory
|
||||
selectedView.value = 'all'
|
||||
selectedSubcategory.value = null
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
const handleTabSelection = (selection: WorkflowTemplates | null) => {
|
||||
if (selection !== null) {
|
||||
selectTemplateCategory(selection)
|
||||
const handleSubcategory = (subcategory: TemplateSubcategory) => {
|
||||
selectedSubcategory.value = subcategory
|
||||
|
||||
// On small screens, close the sidebar when a category is selected
|
||||
if (isSmallScreen.value) {
|
||||
isSideNavOpen.value = false
|
||||
}
|
||||
// On small screens, close the sidebar when a subcategory is selected
|
||||
if (isSmallScreen.value) {
|
||||
isSideNavOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewSelection = (view: 'all' | 'recent') => {
|
||||
selectedView.value = view
|
||||
// Clear subcategory selection when switching to header views
|
||||
selectedSubcategory.value = null
|
||||
}
|
||||
|
||||
const handleLoadWorkflow = async (id: string) => {
|
||||
if (!isReady.value || !selectedTemplate.value) return false
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="px-4">
|
||||
<h3 class="px-8 text-base font-medium">
|
||||
<span>{{ $t('templateWorkflows.title') }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,50 +1,142 @@
|
||||
<template>
|
||||
<ScrollPanel class="w-80" style="height: calc(83vh - 48px)">
|
||||
<Listbox
|
||||
:model-value="selectedTab"
|
||||
:options="tabs"
|
||||
option-group-label="label"
|
||||
option-label="localizedTitle"
|
||||
option-group-children="modules"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
list: { class: 'p-0' },
|
||||
option: { class: 'px-12 py-3 text-lg' },
|
||||
optionGroup: { class: 'p-0 text-left text-inherit' }
|
||||
}"
|
||||
list-style="max-height:unset"
|
||||
@update:model-value="handleTabSelection"
|
||||
<ScrollPanel class="w-60" style="height: calc(83vh - 48px)">
|
||||
<!-- View Selection Header -->
|
||||
<div
|
||||
class="text-left py-3 border-b border-surface-200 dark-theme:border-surface-700"
|
||||
>
|
||||
<template #optiongroup="slotProps">
|
||||
<div class="text-left py-3 px-12">
|
||||
<h2 class="text-lg">
|
||||
{{ slotProps.option.label }}
|
||||
</h2>
|
||||
<Listbox
|
||||
:model-value="selectedView"
|
||||
:options="viewOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:multiple="false"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
list: { class: 'p-0' },
|
||||
header: { class: 'px-0' },
|
||||
option: {
|
||||
class: 'px-0 py-2 text-sm font-medium flex items-center gap-1'
|
||||
}
|
||||
}"
|
||||
@update:model-value="handleViewSelection"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex self-center items-center gap-1 py-1 px-4">
|
||||
<i :class="slotProps.option.icon"></i>
|
||||
<div>{{ slotProps.option.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<!-- Template Groups and Subcategories -->
|
||||
<div class="w-full">
|
||||
<div v-for="group in props.tabs" :key="group.label" class="mb-2">
|
||||
<!-- Group Header -->
|
||||
<div class="text-left py-1">
|
||||
<h3
|
||||
class="text-muted text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 px-4"
|
||||
>
|
||||
{{ group.label }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
|
||||
<!-- Subcategories as Listbox -->
|
||||
<Listbox
|
||||
:model-value="selectedSubcategory"
|
||||
:options="group.subcategories"
|
||||
option-label="label"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
list: { class: 'p-0' },
|
||||
option: { class: 'px-4 py-3 text-sm' }
|
||||
}"
|
||||
@update:model-value="handleSubcategorySelection"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center">
|
||||
<div>{{ slotProps.option.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
TemplateGroup,
|
||||
WorkflowTemplates
|
||||
TemplateSubcategory
|
||||
} from '@/types/workflowTemplateTypes'
|
||||
|
||||
defineProps<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
tabs: TemplateGroup[]
|
||||
selectedTab: WorkflowTemplates | null
|
||||
selectedSubcategory: TemplateSubcategory | null
|
||||
selectedView: 'all' | 'recent' | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedTab', tab: WorkflowTemplates): void
|
||||
(e: 'update:selectedSubcategory', subcategory: TemplateSubcategory): void
|
||||
(e: 'update:selectedView', view: 'all' | 'recent'): void
|
||||
}>()
|
||||
|
||||
const handleTabSelection = (tab: WorkflowTemplates) => {
|
||||
emit('update:selectedTab', tab)
|
||||
const viewOptions = computed(() => {
|
||||
// Create a comprehensive "All Templates" subcategory that aggregates all modules
|
||||
const allTemplatesSubcategory = {
|
||||
label: t('templateWorkflows.view.allTemplates', 'All Templates'),
|
||||
modules: props.tabs.flatMap((group: TemplateGroup) =>
|
||||
group.subcategories.flatMap(
|
||||
(subcategory: TemplateSubcategory) => subcategory.modules
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const recentItemsSubcategory = {
|
||||
label: t('templateWorkflows.view.recentItems', 'Recent Items'),
|
||||
modules: [] // Could be populated with recent templates if needed
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...allTemplatesSubcategory,
|
||||
icon: 'pi pi-list mr-2'
|
||||
},
|
||||
{
|
||||
...recentItemsSubcategory,
|
||||
icon: 'pi pi-clock mr-2'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const handleSubcategorySelection = (subcategory: TemplateSubcategory) => {
|
||||
emit('update:selectedSubcategory', subcategory)
|
||||
}
|
||||
|
||||
const handleViewSelection = (subcategory: TemplateSubcategory | null) => {
|
||||
// Prevent deselection - always keep a view selected
|
||||
if (subcategory !== null) {
|
||||
// Emit as subcategory selection since these now have comprehensive module data
|
||||
emit('update:selectedSubcategory', subcategory)
|
||||
|
||||
// Also emit view selection for backward compatibility
|
||||
const viewValue = subcategory.label.includes('All Templates')
|
||||
? 'all'
|
||||
: 'recent'
|
||||
emit('update:selectedView', viewValue)
|
||||
}
|
||||
// If subcategory is null, we don't emit anything, keeping the current selection
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
:deep(.p-listbox-header) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,55 +2,133 @@ import { type Ref, computed, ref } from 'vue'
|
||||
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
export type SortOption = 'recommended' | 'alphabetical' | 'newest'
|
||||
|
||||
export interface TemplateFilterOptions {
|
||||
searchQuery?: string
|
||||
selectedModels?: string[]
|
||||
selectedSubcategory?: string | null
|
||||
sortBy?: SortOption
|
||||
}
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
) {
|
||||
const searchQuery = ref('')
|
||||
const selectedModels = ref<string[]>([])
|
||||
const selectedSubcategory = ref<string | null>(null)
|
||||
const sortBy = ref<SortOption>('recommended')
|
||||
|
||||
const templatesArray = computed(() => {
|
||||
const templateData = 'value' in templates ? templates.value : templates
|
||||
return Array.isArray(templateData) ? templateData : []
|
||||
})
|
||||
|
||||
// Get unique subcategories (tags) from current templates
|
||||
const availableSubcategories = computed(() => {
|
||||
const subcategorySet = new Set<string>()
|
||||
templatesArray.value.forEach((template) => {
|
||||
template.tags?.forEach((tag) => subcategorySet.add(tag))
|
||||
})
|
||||
return Array.from(subcategorySet).sort()
|
||||
})
|
||||
|
||||
// Get unique models from all current templates (don't filter by subcategory for model list)
|
||||
const availableModels = computed(() => {
|
||||
const modelSet = new Set<string>()
|
||||
|
||||
templatesArray.value.forEach((template) => {
|
||||
template.models?.forEach((model) => modelSet.add(model))
|
||||
})
|
||||
return Array.from(modelSet).sort()
|
||||
})
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const templateData = templatesArray.value
|
||||
let templateData = templatesArray.value
|
||||
if (templateData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!searchQuery.value.trim()) {
|
||||
return templateData
|
||||
// Filter by search query
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
templateData = templateData.filter((template) => {
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.title,
|
||||
template.description,
|
||||
template.sourceModule,
|
||||
...(template.tags || [])
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return templateData.filter((template) => {
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.sourceModule
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
// Filter by subcategory
|
||||
if (selectedSubcategory.value) {
|
||||
templateData = templateData.filter((template) =>
|
||||
template.tags?.includes(selectedSubcategory.value!)
|
||||
)
|
||||
}
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
// Filter by selected models
|
||||
if (selectedModels.value.length > 0) {
|
||||
templateData = templateData.filter((template) =>
|
||||
template.models?.some((model) => selectedModels.value.includes(model))
|
||||
)
|
||||
}
|
||||
|
||||
// Sort templates
|
||||
const sortedData = [...templateData]
|
||||
switch (sortBy.value) {
|
||||
case 'alphabetical':
|
||||
sortedData.sort((a, b) =>
|
||||
(a.title || a.name).localeCompare(b.title || b.name)
|
||||
)
|
||||
break
|
||||
case 'newest':
|
||||
sortedData.sort((a, b) => {
|
||||
const dateA = new Date(a.date || '1970-01-01')
|
||||
const dateB = new Date(b.date || '1970-01-01')
|
||||
return dateB.getTime() - dateA.getTime()
|
||||
})
|
||||
break
|
||||
case 'recommended':
|
||||
default:
|
||||
// Keep original order for recommended (assumes templates are already in recommended order)
|
||||
break
|
||||
}
|
||||
|
||||
return sortedData
|
||||
})
|
||||
|
||||
const resetFilters = () => {
|
||||
searchQuery.value = ''
|
||||
selectedModels.value = []
|
||||
selectedSubcategory.value = null
|
||||
sortBy.value = 'recommended'
|
||||
}
|
||||
|
||||
const resetModelFilters = () => {
|
||||
selectedModels.value = []
|
||||
}
|
||||
|
||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
selectedModels,
|
||||
selectedSubcategory,
|
||||
sortBy,
|
||||
availableSubcategories,
|
||||
availableModels,
|
||||
filteredTemplates,
|
||||
filteredCount,
|
||||
resetFilters
|
||||
resetFilters,
|
||||
resetModelFilters
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,14 @@ export function useTemplateWorkflows() {
|
||||
*/
|
||||
const selectFirstTemplateCategory = () => {
|
||||
if (allTemplateGroups.value.length > 0) {
|
||||
const firstCategory = allTemplateGroups.value[0].modules[0]
|
||||
selectTemplateCategory(firstCategory)
|
||||
const firstGroup = allTemplateGroups.value[0]
|
||||
if (
|
||||
firstGroup.subcategories.length > 0 &&
|
||||
firstGroup.subcategories[0].modules.length > 0
|
||||
) {
|
||||
const firstCategory = firstGroup.subcategories[0].modules[0]
|
||||
selectTemplateCategory(firstCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +69,9 @@ export function useTemplateWorkflows() {
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
// sourceModule === 'default'
|
||||
api.fileURL(`/templates/${template.name}-1`)
|
||||
// : api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
@@ -106,16 +112,21 @@ export function useTemplateWorkflows() {
|
||||
try {
|
||||
// Handle "All" category as a special case
|
||||
if (sourceModule === 'all') {
|
||||
// Find "All" category in the ComfyUI Examples group
|
||||
const comfyExamplesGroup = allTemplateGroups.value.find(
|
||||
(g) =>
|
||||
g.label ===
|
||||
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
||||
// Find "All" category in the USE CASES group
|
||||
const useCasesGroup = allTemplateGroups.value.find(
|
||||
(g) => g.label === t('templateWorkflows.group.useCases', 'USE CASES')
|
||||
)
|
||||
const allCategory = comfyExamplesGroup?.modules.find(
|
||||
(m) => m.moduleName === 'all'
|
||||
const allSubcategory = useCasesGroup?.subcategories.find(
|
||||
(s) =>
|
||||
s.label ===
|
||||
t('templateWorkflows.subcategory.allTemplates', 'All Templates')
|
||||
)
|
||||
const allCategory = allSubcategory?.modules.find(
|
||||
(m: WorkflowTemplates) => m.moduleName === 'all'
|
||||
)
|
||||
const template = allCategory?.templates.find(
|
||||
(t: TemplateInfo) => t.name === id
|
||||
)
|
||||
const template = allCategory?.templates.find((t) => t.name === id)
|
||||
|
||||
if (!template || !template.sourceModule) return false
|
||||
|
||||
|
||||
@@ -552,6 +552,15 @@
|
||||
"title": "Get Started with a Template",
|
||||
"loadingMore": "Loading more templates...",
|
||||
"searchPlaceholder": "Search templates...",
|
||||
"modelFilter": "Model",
|
||||
"sort": "Sort",
|
||||
"sortRecommended": "Recommended",
|
||||
"sortAlphabetical": "Alphabetical",
|
||||
"sortNewest": "Newest",
|
||||
"modelsSelected": "{count} models",
|
||||
"allSubcategories": "All",
|
||||
"tutorial": "Tutorial",
|
||||
"useTemplate": "Use Template",
|
||||
"category": {
|
||||
"ComfyUI Examples": "ComfyUI Examples",
|
||||
"Custom Nodes": "Custom Nodes",
|
||||
@@ -569,6 +578,26 @@
|
||||
"LLM API": "LLM API",
|
||||
"All": "All Templates"
|
||||
},
|
||||
"group": {
|
||||
"useCases": "USE CASES",
|
||||
"toolsBuilding": "TOOLS & BUILDING",
|
||||
"customCommunity": "CUSTOM & COMMUNITY"
|
||||
},
|
||||
"subcategory": {
|
||||
"allTemplates": "All Templates",
|
||||
"imageCreation": "Image Creation",
|
||||
"videoAnimation": "Video & Animation",
|
||||
"spatial": "3D & Spatial",
|
||||
"audio": "Audio",
|
||||
"advancedApis": "Advanced APIs",
|
||||
"postProcessing": "Post-Processing & Utilities",
|
||||
"customNodes": "Custom Nodes",
|
||||
"communityPicks": "Community Picks"
|
||||
},
|
||||
"view": {
|
||||
"allTemplates": "All Templates",
|
||||
"recentItems": "Recent Items"
|
||||
},
|
||||
"templateDescription": {
|
||||
"Basics": {
|
||||
"default": "Generate images from text prompts.",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { groupBy } from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
@@ -101,49 +100,6 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
)
|
||||
})
|
||||
|
||||
// Create an "All" category that combines all templates
|
||||
const createAllCategory = () => {
|
||||
// First, get core templates with source module added
|
||||
const coreTemplatesWithSourceModule = coreTemplates.value.flatMap(
|
||||
(category) =>
|
||||
// For each template in each category, add the sourceModule and pass through any localized fields
|
||||
category.templates.map((template) => {
|
||||
// Get localized template with its original category title for i18n lookup
|
||||
const localizedTemplate = addLocalizedFieldsToTemplate(
|
||||
template,
|
||||
category.title
|
||||
)
|
||||
return {
|
||||
...localizedTemplate,
|
||||
sourceModule: category.moduleName
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Now handle custom templates
|
||||
const customTemplatesWithSourceModule = Object.entries(
|
||||
customTemplates.value
|
||||
).flatMap(([moduleName, templates]) =>
|
||||
templates.map((name) => ({
|
||||
name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: name,
|
||||
sourceModule: moduleName
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
moduleName: 'all',
|
||||
title: 'All',
|
||||
localizedTitle: st('templateWorkflows.category.All', 'All Templates'),
|
||||
templates: [
|
||||
...coreTemplatesWithSourceModule,
|
||||
...customTemplatesWithSourceModule
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const groupedTemplates = computed<TemplateGroup[]>(() => {
|
||||
// Get regular categories
|
||||
const allTemplates = [
|
||||
@@ -168,32 +124,156 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
)
|
||||
]
|
||||
|
||||
// Group templates by their main category
|
||||
const groupedByCategory = Object.entries(
|
||||
groupBy(allTemplates, (template) =>
|
||||
template.moduleName === 'default'
|
||||
? st(
|
||||
'templateWorkflows.category.ComfyUI Examples',
|
||||
'ComfyUI Examples'
|
||||
)
|
||||
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
|
||||
)
|
||||
).map(([label, modules]) => ({ label, modules }))
|
||||
// Create subcategories based on template types and content
|
||||
|
||||
// Insert the "All" category at the top of the "ComfyUI Examples" group
|
||||
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
|
||||
(group) =>
|
||||
group.label ===
|
||||
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
||||
// USE CASES SUBCATEGORIES
|
||||
const imageCreationTemplates = allTemplates.filter((template) =>
|
||||
['Basics', 'Flux', 'Image'].includes(template.title)
|
||||
)
|
||||
|
||||
if (comfyExamplesGroupIndex !== -1) {
|
||||
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
|
||||
createAllCategory()
|
||||
)
|
||||
const videoAnimationTemplates = allTemplates.filter(
|
||||
(template) => template.title === 'Video'
|
||||
)
|
||||
|
||||
const spatialTemplates = allTemplates.filter((template) =>
|
||||
['3D', 'Area Composition'].includes(template.title)
|
||||
)
|
||||
|
||||
const audioTemplates = allTemplates.filter(
|
||||
(template) => template.title === 'Audio'
|
||||
)
|
||||
|
||||
// TOOLS & BUILDING SUBCATEGORIES
|
||||
const advancedApisTemplates = allTemplates.filter((template) =>
|
||||
['Image API', 'Video API', '3D API', 'LLM API'].includes(template.title)
|
||||
)
|
||||
|
||||
const postProcessingTemplates = allTemplates.filter((template) =>
|
||||
['Upscaling', 'ControlNet'].includes(template.title)
|
||||
)
|
||||
|
||||
// CUSTOM & COMMUNITY SUBCATEGORIES
|
||||
const customNodesTemplates = allTemplates.filter(
|
||||
(template) =>
|
||||
template.moduleName !== 'default' &&
|
||||
!advancedApisTemplates.includes(template)
|
||||
)
|
||||
|
||||
const communityPicksTemplates: WorkflowTemplates[] = [] // This could be populated with featured community content
|
||||
|
||||
const groups: TemplateGroup[] = []
|
||||
|
||||
// USE CASES GROUP
|
||||
const useCasesSubcategories = []
|
||||
|
||||
if (imageCreationTemplates.length > 0) {
|
||||
useCasesSubcategories.push({
|
||||
label: st(
|
||||
'templateWorkflows.subcategory.imageCreation',
|
||||
'Image Creation'
|
||||
),
|
||||
modules: imageCreationTemplates
|
||||
})
|
||||
}
|
||||
|
||||
return groupedByCategory
|
||||
if (videoAnimationTemplates.length > 0) {
|
||||
useCasesSubcategories.push({
|
||||
label: st(
|
||||
'templateWorkflows.subcategory.videoAnimation',
|
||||
'Video & Animation'
|
||||
),
|
||||
modules: videoAnimationTemplates
|
||||
})
|
||||
}
|
||||
|
||||
if (spatialTemplates.length > 0) {
|
||||
useCasesSubcategories.push({
|
||||
label: st('templateWorkflows.subcategory.spatial', '3D & Spatial'),
|
||||
modules: spatialTemplates
|
||||
})
|
||||
}
|
||||
|
||||
if (audioTemplates.length > 0) {
|
||||
useCasesSubcategories.push({
|
||||
label: st('templateWorkflows.subcategory.audio', 'Audio'),
|
||||
modules: audioTemplates
|
||||
})
|
||||
}
|
||||
|
||||
if (useCasesSubcategories.length > 0) {
|
||||
groups.push({
|
||||
label: st('templateWorkflows.group.useCases', 'USE CASES'),
|
||||
subcategories: useCasesSubcategories
|
||||
})
|
||||
}
|
||||
|
||||
// TOOLS & BUILDING GROUP
|
||||
const toolsBuildingSubcategories = []
|
||||
|
||||
if (advancedApisTemplates.length > 0) {
|
||||
toolsBuildingSubcategories.push({
|
||||
label: st(
|
||||
'templateWorkflows.subcategory.advancedApis',
|
||||
'Advanced APIs'
|
||||
),
|
||||
modules: advancedApisTemplates
|
||||
})
|
||||
}
|
||||
|
||||
if (postProcessingTemplates.length > 0) {
|
||||
toolsBuildingSubcategories.push({
|
||||
label: st(
|
||||
'templateWorkflows.subcategory.postProcessing',
|
||||
'Post-Processing & Utilities'
|
||||
),
|
||||
modules: postProcessingTemplates
|
||||
})
|
||||
}
|
||||
|
||||
if (toolsBuildingSubcategories.length > 0) {
|
||||
groups.push({
|
||||
label: st(
|
||||
'templateWorkflows.group.toolsBuilding',
|
||||
'TOOLS & BUILDING'
|
||||
),
|
||||
subcategories: toolsBuildingSubcategories
|
||||
})
|
||||
}
|
||||
|
||||
// CUSTOM & COMMUNITY GROUP
|
||||
const customCommunitySubcategories = []
|
||||
|
||||
if (customNodesTemplates.length > 0) {
|
||||
customCommunitySubcategories.push({
|
||||
label: st(
|
||||
'templateWorkflows.subcategory.customNodes',
|
||||
'Custom Nodes'
|
||||
),
|
||||
modules: customNodesTemplates
|
||||
})
|
||||
}
|
||||
|
||||
if (communityPicksTemplates.length > 0) {
|
||||
customCommunitySubcategories.push({
|
||||
label: st(
|
||||
'templateWorkflows.subcategory.communityPicks',
|
||||
'Community Picks'
|
||||
),
|
||||
modules: communityPicksTemplates
|
||||
})
|
||||
}
|
||||
|
||||
if (customCommunitySubcategories.length > 0) {
|
||||
groups.push({
|
||||
label: st(
|
||||
'templateWorkflows.group.customCommunity',
|
||||
'CUSTOM & COMMUNITY'
|
||||
),
|
||||
subcategories: customCommunitySubcategories
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
async function loadWorkflowTemplates() {
|
||||
|
||||
@@ -12,16 +12,25 @@ export interface TemplateInfo {
|
||||
localizedTitle?: string
|
||||
localizedDescription?: string
|
||||
sourceModule?: string
|
||||
tags?: string[]
|
||||
models?: string[]
|
||||
date?: string
|
||||
}
|
||||
|
||||
export interface WorkflowTemplates {
|
||||
moduleName: string
|
||||
templates: TemplateInfo[]
|
||||
title: string
|
||||
localizedTitle?: string
|
||||
}
|
||||
|
||||
export interface TemplateSubcategory {
|
||||
label: string
|
||||
modules: WorkflowTemplates[]
|
||||
}
|
||||
|
||||
export interface TemplateGroup {
|
||||
label: string
|
||||
icon?: string
|
||||
modules: WorkflowTemplates[]
|
||||
subcategories: TemplateSubcategory[]
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
'/workflow_templates': {
|
||||
target: DEV_SERVER_COMFYUI_URL
|
||||
target: 'http://localhost:1238'
|
||||
},
|
||||
|
||||
// Proxy extension assets (images/videos) under /extensions to the ComfyUI backend
|
||||
@@ -67,7 +67,7 @@ export default defineConfig({
|
||||
...(!DISABLE_TEMPLATES_PROXY
|
||||
? {
|
||||
'/templates': {
|
||||
target: DEV_SERVER_COMFYUI_URL
|
||||
target: 'http://localhost:1238'
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
|
||||
Reference in New Issue
Block a user