mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
## Summary Assorted cleanup opportunities found while working through the Vue node rendering logic cleanup. ## Review Focus Am I wrong that the readonly logic was never actually executing because it was defined as False in GraphCanvas when making each LGraphNode? Is there an edge case or some other reason that the ResizeObserver wouldn't work as a single signal to resize the canvas? ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5869-Cleanup-YAGNI-readonly-props-private-swap-on-ComfyApp-Canvas-resize-events-simplificat-27e6d73d3650811ba1dcf29e8d43091e) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
757 lines
23 KiB
Vue
757 lines
23 KiB
Vue
<template>
|
|
<BaseModalLayout
|
|
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
|
class="workflow-template-selector-dialog"
|
|
>
|
|
<template #leftPanel>
|
|
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
|
|
<template #header-icon>
|
|
<i class="icon-[comfy--template]" />
|
|
</template>
|
|
<template #header-title>
|
|
<span class="text-neutral text-base">{{
|
|
$t('sideToolbar.templates', 'Templates')
|
|
}}</span>
|
|
</template>
|
|
</LeftSidePanel>
|
|
</template>
|
|
|
|
<template #header>
|
|
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
|
|
</template>
|
|
|
|
<template #header-right-area>
|
|
<div class="flex gap-2">
|
|
<IconTextButton
|
|
v-if="filteredCount !== totalCount"
|
|
type="secondary"
|
|
:label="$t('templateWorkflows.resetFilters', 'Clear Filters')"
|
|
@click="resetFilters"
|
|
>
|
|
<template #icon>
|
|
<i-lucide:filter-x />
|
|
</template>
|
|
</IconTextButton>
|
|
</div>
|
|
</template>
|
|
|
|
<template #contentFilter>
|
|
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap">
|
|
<!-- Model Filter -->
|
|
<MultiSelect
|
|
v-model="selectedModelObjects"
|
|
v-model:search-query="modelSearchText"
|
|
class="w-[250px]"
|
|
:label="modelFilterLabel"
|
|
:options="modelOptions"
|
|
:show-search-box="true"
|
|
:show-selected-count="true"
|
|
:show-clear-button="true"
|
|
>
|
|
<template #icon>
|
|
<i-lucide:cpu />
|
|
</template>
|
|
</MultiSelect>
|
|
|
|
<!-- Use Case Filter -->
|
|
<MultiSelect
|
|
v-model="selectedUseCaseObjects"
|
|
:label="useCaseFilterLabel"
|
|
:options="useCaseOptions"
|
|
:show-search-box="true"
|
|
:show-selected-count="true"
|
|
:show-clear-button="true"
|
|
>
|
|
<template #icon>
|
|
<i-lucide:target />
|
|
</template>
|
|
</MultiSelect>
|
|
|
|
<!-- License Filter -->
|
|
<MultiSelect
|
|
v-model="selectedLicenseObjects"
|
|
:label="licenseFilterLabel"
|
|
:options="licenseOptions"
|
|
:show-search-box="true"
|
|
:show-selected-count="true"
|
|
:show-clear-button="true"
|
|
>
|
|
<template #icon>
|
|
<i-lucide:file-text />
|
|
</template>
|
|
</MultiSelect>
|
|
|
|
<!-- Sort Options -->
|
|
<div class="absolute right-5">
|
|
<SingleSelect
|
|
v-model="sortBy"
|
|
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
|
:options="sortOptions"
|
|
class="min-w-[270px]"
|
|
>
|
|
<template #icon>
|
|
<i-lucide:arrow-up-down />
|
|
</template>
|
|
</SingleSelect>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="!isLoading"
|
|
class="px-6 pt-4 pb-2 text-2xl font-semibold text-neutral"
|
|
>
|
|
<span>
|
|
{{ pageTitle }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template #content>
|
|
<!-- No Results State (only show when loaded and no results) -->
|
|
<div
|
|
v-if="!isLoading && filteredTemplates.length === 0"
|
|
class="flex flex-col items-center justify-center h-64 text-neutral-500"
|
|
>
|
|
<i-lucide:search class="w-12 h-12 mb-4 opacity-50" />
|
|
<p class="text-lg mb-2">
|
|
{{ $t('templateWorkflows.noResults', 'No templates found') }}
|
|
</p>
|
|
<p class="text-sm">
|
|
{{
|
|
$t(
|
|
'templateWorkflows.noResultsHint',
|
|
'Try adjusting your search or filters'
|
|
)
|
|
}}
|
|
</p>
|
|
</div>
|
|
<div v-else>
|
|
<!-- Title -->
|
|
<span
|
|
v-if="isLoading"
|
|
class="inline-block h-8 w-48 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
|
></span>
|
|
|
|
<!-- Template Cards Grid -->
|
|
<div
|
|
:key="templateListKey"
|
|
:style="gridStyle"
|
|
data-testid="template-workflows-content"
|
|
>
|
|
<!-- Loading Skeletons (show while loading initial data) -->
|
|
<CardContainer
|
|
v-for="n in isLoading ? 12 : 0"
|
|
:key="`initial-skeleton-${n}`"
|
|
ratio="smallSquare"
|
|
type="workflow-template-card"
|
|
>
|
|
<template #top>
|
|
<CardTop ratio="landscape">
|
|
<template #default>
|
|
<div
|
|
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
|
|
></div>
|
|
</template>
|
|
</CardTop>
|
|
</template>
|
|
<template #bottom>
|
|
<CardBottom>
|
|
<div class="px-4 py-3">
|
|
<div
|
|
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
|
|
></div>
|
|
<div
|
|
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
|
></div>
|
|
</div>
|
|
</CardBottom>
|
|
</template>
|
|
</CardContainer>
|
|
|
|
<!-- Actual Template Cards -->
|
|
<CardContainer
|
|
v-for="template in isLoading ? [] : displayTemplates"
|
|
:key="template.name"
|
|
ref="cardRefs"
|
|
ratio="smallSquare"
|
|
type="workflow-template-card"
|
|
:data-testid="`template-workflow-${template.name}`"
|
|
@mouseenter="hoveredTemplate = template.name"
|
|
@mouseleave="hoveredTemplate = null"
|
|
@click="onLoadWorkflow(template)"
|
|
>
|
|
<template #top>
|
|
<CardTop ratio="square">
|
|
<template #default>
|
|
<!-- Template Thumbnail -->
|
|
<div
|
|
class="w-full h-full relative rounded-lg overflow-hidden"
|
|
>
|
|
<template v-if="template.mediaType === 'audio'">
|
|
<AudioThumbnail :src="getBaseThumbnailSrc(template)" />
|
|
</template>
|
|
<template
|
|
v-else-if="template.thumbnailVariant === 'compareSlider'"
|
|
>
|
|
<CompareSliderThumbnail
|
|
:base-image-src="getBaseThumbnailSrc(template)"
|
|
:overlay-image-src="getOverlayThumbnailSrc(template)"
|
|
:alt="
|
|
getTemplateTitle(
|
|
template,
|
|
getEffectiveSourceModule(template)
|
|
)
|
|
"
|
|
:is-hovered="hoveredTemplate === template.name"
|
|
:is-video="
|
|
template.mediaType === 'video' ||
|
|
template.mediaSubtype === 'webp'
|
|
"
|
|
/>
|
|
</template>
|
|
<template
|
|
v-else-if="template.thumbnailVariant === 'hoverDissolve'"
|
|
>
|
|
<HoverDissolveThumbnail
|
|
:base-image-src="getBaseThumbnailSrc(template)"
|
|
:overlay-image-src="getOverlayThumbnailSrc(template)"
|
|
:alt="
|
|
getTemplateTitle(
|
|
template,
|
|
getEffectiveSourceModule(template)
|
|
)
|
|
"
|
|
:is-hovered="hoveredTemplate === template.name"
|
|
:is-video="
|
|
template.mediaType === 'video' ||
|
|
template.mediaSubtype === 'webp'
|
|
"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<DefaultThumbnail
|
|
:src="getBaseThumbnailSrc(template)"
|
|
:alt="
|
|
getTemplateTitle(
|
|
template,
|
|
getEffectiveSourceModule(template)
|
|
)
|
|
"
|
|
:is-hovered="hoveredTemplate === template.name"
|
|
:is-video="
|
|
template.mediaType === 'video' ||
|
|
template.mediaSubtype === 'webp'
|
|
"
|
|
:hover-zoom="
|
|
template.thumbnailVariant === 'zoomHover' ? 16 : 5
|
|
"
|
|
/>
|
|
</template>
|
|
<ProgressSpinner
|
|
v-if="loadingTemplate === template.name"
|
|
class="absolute inset-0 z-10 w-12 h-12 m-auto"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<template #bottom-right>
|
|
<template v-if="template.tags && template.tags.length > 0">
|
|
<SquareChip
|
|
v-for="tag in template.tags"
|
|
:key="tag"
|
|
:label="tag"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</CardTop>
|
|
</template>
|
|
<template #bottom>
|
|
<CardBottom>
|
|
<div class="flex flex-col gap-2 pt-3">
|
|
<h3
|
|
class="line-clamp-1 text-sm m-0"
|
|
:title="
|
|
getTemplateTitle(
|
|
template,
|
|
getEffectiveSourceModule(template)
|
|
)
|
|
"
|
|
>
|
|
{{
|
|
getTemplateTitle(
|
|
template,
|
|
getEffectiveSourceModule(template)
|
|
)
|
|
}}
|
|
</h3>
|
|
<div class="flex justify-between gap-2">
|
|
<div class="flex-1">
|
|
<p
|
|
class="line-clamp-2 text-sm text-muted m-0"
|
|
:title="getTemplateDescription(template)"
|
|
>
|
|
{{ getTemplateDescription(template) }}
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-if="template.tutorialUrl"
|
|
class="flex flex-col-reverse justify-center"
|
|
>
|
|
<IconButton
|
|
v-if="hoveredTemplate === template.name"
|
|
v-tooltip.bottom="$t('g.seeTutorial')"
|
|
v-bind="$attrs"
|
|
type="primary"
|
|
size="sm"
|
|
@click.stop="openTutorial(template)"
|
|
>
|
|
<i class="icon-[lucide--info] size-4" />
|
|
</IconButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardBottom>
|
|
</template>
|
|
</CardContainer>
|
|
|
|
<!-- Loading More Skeletons -->
|
|
<CardContainer
|
|
v-for="n in isLoadingMore ? 6 : 0"
|
|
:key="`skeleton-${n}`"
|
|
ratio="smallSquare"
|
|
type="workflow-template-card"
|
|
>
|
|
<template #top>
|
|
<CardTop ratio="square">
|
|
<template #default>
|
|
<div
|
|
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
|
|
></div>
|
|
</template>
|
|
</CardTop>
|
|
</template>
|
|
<template #bottom>
|
|
<CardBottom>
|
|
<div class="px-4 py-3">
|
|
<div
|
|
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
|
|
></div>
|
|
<div
|
|
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
|
></div>
|
|
</div>
|
|
</CardBottom>
|
|
</template>
|
|
</CardContainer>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load More Trigger -->
|
|
<div
|
|
v-if="!isLoading && hasMoreTemplates"
|
|
ref="loadTrigger"
|
|
class="w-full h-4 flex justify-center items-center mt-4"
|
|
>
|
|
<div v-if="isLoadingMore" class="text-sm text-muted">
|
|
{{ $t('templateWorkflows.loadingMore', 'Loading more...') }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Results Summary -->
|
|
<div
|
|
v-if="!isLoading"
|
|
class="mt-6 px-6 text-sm text-neutral-600 dark-theme:text-neutral-400"
|
|
>
|
|
{{
|
|
$t('templateWorkflows.resultsCount', {
|
|
count: filteredCount,
|
|
total: totalCount
|
|
})
|
|
}}
|
|
</div>
|
|
</template>
|
|
</BaseModalLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useAsyncState } from '@vueuse/core'
|
|
import ProgressSpinner from 'primevue/progressspinner'
|
|
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import IconButton from '@/components/button/IconButton.vue'
|
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
|
import CardBottom from '@/components/card/CardBottom.vue'
|
|
import CardContainer from '@/components/card/CardContainer.vue'
|
|
import CardTop from '@/components/card/CardTop.vue'
|
|
import SquareChip from '@/components/chip/SquareChip.vue'
|
|
import MultiSelect from '@/components/input/MultiSelect.vue'
|
|
import SearchBox from '@/components/input/SearchBox.vue'
|
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
|
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 BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
|
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
|
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
|
import { useLazyPagination } from '@/composables/useLazyPagination'
|
|
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
|
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
|
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
|
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
|
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
|
import { OnCloseKey } from '@/types/widgetTypes'
|
|
import { createGridStyle } from '@/utils/gridUtil'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const { onClose } = defineProps<{
|
|
onClose: () => void
|
|
}>()
|
|
|
|
provide(OnCloseKey, onClose)
|
|
|
|
// Workflow templates store and composable
|
|
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
|
const {
|
|
loadTemplates,
|
|
loadWorkflowTemplate,
|
|
getTemplateThumbnailUrl,
|
|
getTemplateTitle,
|
|
getTemplateDescription
|
|
} = useTemplateWorkflows()
|
|
|
|
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
|
template.sourceModule || 'default'
|
|
|
|
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
|
const sm = getEffectiveSourceModule(template)
|
|
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
|
}
|
|
|
|
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
|
|
const sm = getEffectiveSourceModule(template)
|
|
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
|
|
}
|
|
|
|
// Open tutorial in new tab
|
|
const openTutorial = (template: TemplateInfo) => {
|
|
if (template.tutorialUrl) {
|
|
window.open(template.tutorialUrl, '_blank')
|
|
}
|
|
}
|
|
|
|
// Get navigation items from the store, with skeleton items while loading
|
|
const navItems = computed<(NavItemData | NavGroupData)[]>(() => {
|
|
// Show skeleton navigation items while loading
|
|
if (isLoading.value) {
|
|
return [
|
|
{
|
|
id: 'skeleton-all',
|
|
label: 'All Templates',
|
|
icon: 'icon-[lucide--layout-grid]'
|
|
},
|
|
{
|
|
id: 'skeleton-basics',
|
|
label: 'Basics',
|
|
icon: 'icon-[lucide--graduation-cap]'
|
|
},
|
|
{
|
|
title: 'Generation Type',
|
|
items: [
|
|
{ id: 'skeleton-1', label: '...', icon: 'icon-[lucide--loader-2]' },
|
|
{ id: 'skeleton-2', label: '...', icon: 'icon-[lucide--loader-2]' }
|
|
]
|
|
},
|
|
{
|
|
title: 'Closed Source Models',
|
|
items: [
|
|
{ id: 'skeleton-3', label: '...', icon: 'icon-[lucide--loader-2]' }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
return workflowTemplatesStore.navGroupedTemplates
|
|
})
|
|
|
|
const gridStyle = computed(() => createGridStyle())
|
|
|
|
// Get enhanced templates for better filtering
|
|
const allTemplates = computed(() => {
|
|
return workflowTemplatesStore.enhancedTemplates
|
|
})
|
|
|
|
// Filter templates based on selected navigation item
|
|
const navigationFilteredTemplates = computed(() => {
|
|
if (!selectedNavItem.value) {
|
|
return allTemplates.value
|
|
}
|
|
|
|
return workflowTemplatesStore.filterTemplatesByCategory(selectedNavItem.value)
|
|
})
|
|
|
|
// Template filtering
|
|
const {
|
|
searchQuery,
|
|
selectedModels,
|
|
selectedUseCases,
|
|
selectedLicenses,
|
|
sortBy,
|
|
filteredTemplates,
|
|
availableModels,
|
|
availableUseCases,
|
|
availableLicenses,
|
|
filteredCount,
|
|
totalCount,
|
|
resetFilters
|
|
} = useTemplateFiltering(navigationFilteredTemplates)
|
|
|
|
// Convert between string array and object array for MultiSelect component
|
|
const selectedModelObjects = computed({
|
|
get() {
|
|
return selectedModels.value.map((model) => ({ name: model, value: model }))
|
|
},
|
|
set(value: { name: string; value: string }[]) {
|
|
selectedModels.value = value.map((item) => item.value)
|
|
}
|
|
})
|
|
|
|
const selectedUseCaseObjects = computed({
|
|
get() {
|
|
return selectedUseCases.value.map((useCase) => ({
|
|
name: useCase,
|
|
value: useCase
|
|
}))
|
|
},
|
|
set(value: { name: string; value: string }[]) {
|
|
selectedUseCases.value = value.map((item) => item.value)
|
|
}
|
|
})
|
|
|
|
const selectedLicenseObjects = computed({
|
|
get() {
|
|
return selectedLicenses.value.map((license) => ({
|
|
name: license,
|
|
value: license
|
|
}))
|
|
},
|
|
set(value: { name: string; value: string }[]) {
|
|
selectedLicenses.value = value.map((item) => item.value)
|
|
}
|
|
})
|
|
|
|
// Loading states
|
|
const loadingTemplate = ref<string | null>(null)
|
|
const hoveredTemplate = ref<string | null>(null)
|
|
const cardRefs = ref<HTMLElement[]>([])
|
|
|
|
// Force re-render key for templates when sorting changes
|
|
const templateListKey = ref(0)
|
|
|
|
// Navigation
|
|
const selectedNavItem = ref<string | null>('all')
|
|
|
|
// Search text for model filter
|
|
const modelSearchText = ref<string>('')
|
|
|
|
// Filter options
|
|
const modelOptions = computed(() =>
|
|
availableModels.value.map((model) => ({
|
|
name: model,
|
|
value: model
|
|
}))
|
|
)
|
|
|
|
const useCaseOptions = computed(() =>
|
|
availableUseCases.value.map((useCase) => ({
|
|
name: useCase,
|
|
value: useCase
|
|
}))
|
|
)
|
|
|
|
const licenseOptions = computed(() =>
|
|
availableLicenses.value.map((license) => ({
|
|
name: license,
|
|
value: license
|
|
}))
|
|
)
|
|
|
|
// Filter labels
|
|
const modelFilterLabel = computed(() => {
|
|
if (selectedModelObjects.value.length === 0) {
|
|
return t('templateWorkflows.modelFilter', 'Model Filter')
|
|
} else if (selectedModelObjects.value.length === 1) {
|
|
return selectedModelObjects.value[0].name
|
|
} else {
|
|
return t('templateWorkflows.modelsSelected', {
|
|
count: selectedModelObjects.value.length
|
|
})
|
|
}
|
|
})
|
|
|
|
const useCaseFilterLabel = computed(() => {
|
|
if (selectedUseCaseObjects.value.length === 0) {
|
|
return t('templateWorkflows.useCaseFilter', 'Use Case')
|
|
} else if (selectedUseCaseObjects.value.length === 1) {
|
|
return selectedUseCaseObjects.value[0].name
|
|
} else {
|
|
return t('templateWorkflows.useCasesSelected', {
|
|
count: selectedUseCaseObjects.value.length
|
|
})
|
|
}
|
|
})
|
|
|
|
const licenseFilterLabel = computed(() => {
|
|
if (selectedLicenseObjects.value.length === 0) {
|
|
return t('templateWorkflows.licenseFilter', 'License')
|
|
} else if (selectedLicenseObjects.value.length === 1) {
|
|
return selectedLicenseObjects.value[0].name
|
|
} else {
|
|
return t('templateWorkflows.licensesSelected', {
|
|
count: selectedLicenseObjects.value.length
|
|
})
|
|
}
|
|
})
|
|
|
|
// Sort options
|
|
const sortOptions = computed(() => [
|
|
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
|
{
|
|
name: t('templateWorkflows.sort.default', 'Default'),
|
|
value: 'default'
|
|
},
|
|
{
|
|
name: t(
|
|
'templateWorkflows.sort.vramLowToHigh',
|
|
'VRAM Utilization (Low to High)'
|
|
),
|
|
value: 'vram-low-to-high'
|
|
},
|
|
{
|
|
name: t(
|
|
'templateWorkflows.sort.modelSizeLowToHigh',
|
|
'Model Size (Low to High)'
|
|
),
|
|
value: 'model-size-low-to-high'
|
|
},
|
|
{
|
|
name: t('templateWorkflows.sort.alphabetical', 'Alphabetical (A-Z)'),
|
|
value: 'alphabetical'
|
|
}
|
|
])
|
|
|
|
// Lazy pagination setup
|
|
const loadTrigger = ref<HTMLElement | null>(null)
|
|
const shouldUsePagination = computed(() => !searchQuery.value.trim())
|
|
|
|
const {
|
|
paginatedItems: paginatedTemplates,
|
|
isLoading: isLoadingMore,
|
|
hasMoreItems: hasMoreTemplates,
|
|
loadNextPage,
|
|
reset: resetPagination
|
|
} = useLazyPagination(filteredTemplates, { itemsPerPage: 24 }) // Load 24 items per page
|
|
|
|
// Display templates (all when searching, paginated when not)
|
|
const displayTemplates = computed(() => {
|
|
return shouldUsePagination.value
|
|
? paginatedTemplates.value
|
|
: filteredTemplates.value
|
|
})
|
|
|
|
// Set up intersection observer for lazy loading
|
|
useIntersectionObserver(loadTrigger, () => {
|
|
if (
|
|
shouldUsePagination.value &&
|
|
hasMoreTemplates.value &&
|
|
!isLoadingMore.value
|
|
) {
|
|
void loadNextPage()
|
|
}
|
|
})
|
|
|
|
// Reset pagination when filters change
|
|
watch(
|
|
[
|
|
searchQuery,
|
|
selectedNavItem,
|
|
sortBy,
|
|
selectedModels,
|
|
selectedUseCases,
|
|
selectedLicenses
|
|
],
|
|
() => {
|
|
resetPagination()
|
|
// Clear loading state and force re-render of template list
|
|
loadingTemplate.value = null
|
|
templateListKey.value++
|
|
}
|
|
)
|
|
|
|
// Methods
|
|
const onLoadWorkflow = async (template: any) => {
|
|
loadingTemplate.value = template.name
|
|
try {
|
|
await loadWorkflowTemplate(
|
|
template.name,
|
|
getEffectiveSourceModule(template)
|
|
)
|
|
onClose()
|
|
} finally {
|
|
loadingTemplate.value = null
|
|
}
|
|
}
|
|
|
|
const pageTitle = computed(() => {
|
|
const navItem = navItems.value.find((item) =>
|
|
'id' in item
|
|
? item.id === selectedNavItem.value
|
|
: item.items?.some((sub) => sub.id === selectedNavItem.value)
|
|
)
|
|
|
|
if (!navItem) {
|
|
return t('templateWorkflows.allTemplates', 'All Templates')
|
|
}
|
|
|
|
return 'id' in navItem
|
|
? navItem.label
|
|
: navItem.items?.find((i) => i.id === selectedNavItem.value)?.label ||
|
|
t('templateWorkflows.allTemplates', 'All Templates')
|
|
})
|
|
|
|
// Initialize templates loading with useAsyncState
|
|
const { isLoading } = useAsyncState(
|
|
async () => {
|
|
// Run both operations in parallel for better performance
|
|
await Promise.all([
|
|
loadTemplates(),
|
|
workflowTemplatesStore.loadWorkflowTemplates()
|
|
])
|
|
return true
|
|
},
|
|
false, // initial state
|
|
{
|
|
immediate: true // Start loading immediately
|
|
}
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
cardRefs.value = [] // Release DOM refs
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
/* Ensure the workflow template selector dialog fits within provided dialog */
|
|
.workflow-template-selector-dialog.base-widget-layout {
|
|
width: 100% !important;
|
|
max-width: 1400px;
|
|
height: 100% !important;
|
|
aspect-ratio: auto !important;
|
|
}
|
|
|
|
@media (min-width: 1600px) {
|
|
.workflow-template-selector-dialog.base-widget-layout {
|
|
max-width: 1600px;
|
|
}
|
|
}
|
|
</style>
|