mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
## Summary Follow-up to #12183: move the debounced, searcher-driven search input out of `src/renderer/...` and into the shared primitives folder, so both the graph (form dropdown node widget) and the shell UI (templates dialog, right side panel tabs) can use it without crossing the renderer layer. ## Changes - **What**: Renamed and relocated `FormSearchInput` → `AsyncSearchInput` at `src/components/ui/search-input/AsyncSearchInput.vue`, joining the existing `SearchInput` / `SearchAutocomplete` siblings. - **What**: Updated all 9 callers (graph form dropdown, right side panel tabs, templates dialog) to import from the new path/name. Test file moved alongside the component. - **Breaking**: None — pure rename + relocate, behavior is identical. ## Review Focus - New name reflects the component's distinguishing feature (the async `searcher` lifecycle: debounce + cancellation + loading state); see Slack thread. - Slack thread captured the discussion — splitting from #12183 so the perf fix can backport cleanly to the release line. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12185-refactor-promote-FormSearchInput-to-shared-ui-as-AsyncSearchInput-35e6d73d365081c585d8d421ea4353fa) by [Unito](https://www.unito.io) Co-authored-by: Christian Byrne <cbyrne@comfy.org>
864 lines
27 KiB
Vue
864 lines
27 KiB
Vue
<template>
|
|
<BaseModalLayout
|
|
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
|
size="md"
|
|
>
|
|
<template #leftPanelHeaderTitle>
|
|
<i class="icon-[comfy--template]" />
|
|
<h2 class="text-neutral text-base">
|
|
{{ $t('sideToolbar.templates', 'Templates') }}
|
|
</h2>
|
|
</template>
|
|
<template #leftPanel>
|
|
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems" />
|
|
</template>
|
|
|
|
<template #header>
|
|
<AsyncSearchInput
|
|
v-model="searchInput"
|
|
:searcher="applySearchQuery"
|
|
:debounce-ms="400"
|
|
:debounce-max-wait-ms="4000"
|
|
class="h-10 max-w-96 flex-1"
|
|
autofocus
|
|
/>
|
|
</template>
|
|
|
|
<template #header-right-area>
|
|
<div class="flex gap-2">
|
|
<Button
|
|
v-if="filteredCount !== totalCount"
|
|
variant="secondary"
|
|
size="lg"
|
|
@click="resetFilters"
|
|
>
|
|
<i class="icon-[lucide--filter-x]" />
|
|
<span>{{
|
|
$t('templateWorkflows.resetFilters', 'Clear Filters')
|
|
}}</span>
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
|
|
<template #contentFilter>
|
|
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
|
|
<div
|
|
:ref="primeVueOverlay.overlayScopeRef"
|
|
class="flex flex-wrap gap-2"
|
|
>
|
|
<!-- Model Filter -->
|
|
<MultiSelect
|
|
v-model="selectedModelObjects"
|
|
v-model:search-query="modelSearchText"
|
|
class="w-[250px]"
|
|
:label="modelFilterLabel"
|
|
:options="modelOptions"
|
|
:content-style="selectContentStyle"
|
|
:show-search-box="true"
|
|
:show-selected-count="true"
|
|
:show-clear-button="true"
|
|
>
|
|
<template #icon>
|
|
<i class="icon-[lucide--cpu]" />
|
|
</template>
|
|
</MultiSelect>
|
|
|
|
<!-- Use Case Filter -->
|
|
<MultiSelect
|
|
v-model="selectedUseCaseObjects"
|
|
:label="useCaseFilterLabel"
|
|
:options="useCaseOptions"
|
|
:content-style="selectContentStyle"
|
|
:show-search-box="true"
|
|
:show-selected-count="true"
|
|
:show-clear-button="true"
|
|
>
|
|
<template #icon>
|
|
<i class="icon-[lucide--target]" />
|
|
</template>
|
|
</MultiSelect>
|
|
|
|
<!-- Runs On Filter -->
|
|
<MultiSelect
|
|
v-model="selectedRunsOnObjects"
|
|
:label="runsOnFilterLabel"
|
|
:options="runsOnOptions"
|
|
:content-style="selectContentStyle"
|
|
:show-search-box="true"
|
|
:show-selected-count="true"
|
|
:show-clear-button="true"
|
|
>
|
|
<template #icon>
|
|
<i class="icon-[lucide--server]" />
|
|
</template>
|
|
</MultiSelect>
|
|
</div>
|
|
|
|
<!-- Sort Options -->
|
|
<div>
|
|
<SingleSelect
|
|
v-model="sortBy"
|
|
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
|
:options="sortOptions"
|
|
:content-style="selectContentStyle"
|
|
class="w-62.5"
|
|
>
|
|
<template #icon>
|
|
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
|
</template>
|
|
</SingleSelect>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="!isLoading"
|
|
class="text-neutral px-6 pt-4 pb-2 text-2xl font-semibold"
|
|
>
|
|
<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 h-64 flex-col items-center justify-center text-neutral-500"
|
|
>
|
|
<i class="mb-4 icon-[lucide--search] size-12 opacity-50" />
|
|
<p class="mb-2 text-lg">
|
|
{{ $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 animate-pulse rounded-sm bg-dialog-surface"
|
|
></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}`"
|
|
size="compact"
|
|
variant="ghost"
|
|
rounded="lg"
|
|
class="hover:bg-base-background"
|
|
>
|
|
<template #top>
|
|
<CardTop ratio="landscape">
|
|
<template #default>
|
|
<div class="size-full animate-pulse bg-dialog-surface"></div>
|
|
</template>
|
|
</CardTop>
|
|
</template>
|
|
<template #bottom>
|
|
<CardBottom>
|
|
<div class="px-4 py-3">
|
|
<div
|
|
class="mb-2 h-6 animate-pulse rounded-sm bg-dialog-surface"
|
|
></div>
|
|
<div
|
|
class="h-4 animate-pulse rounded-sm bg-dialog-surface"
|
|
></div>
|
|
</div>
|
|
</CardBottom>
|
|
</template>
|
|
</CardContainer>
|
|
|
|
<!-- Actual Template Cards -->
|
|
<CardContainer
|
|
v-for="template in isLoading ? [] : displayTemplates"
|
|
:key="template.name"
|
|
ref="cardRefs"
|
|
size="tall"
|
|
variant="ghost"
|
|
rounded="lg"
|
|
:data-testid="`template-workflow-${template.name}`"
|
|
class="hover:bg-base-background"
|
|
@mouseenter="hoveredTemplate = template.name"
|
|
@mouseleave="hoveredTemplate = null"
|
|
@click="onLoadWorkflow(template)"
|
|
>
|
|
<template #top>
|
|
<CardTop ratio="square">
|
|
<template #default>
|
|
<!-- Template Thumbnail -->
|
|
<div class="relative size-full overflow-hidden rounded-lg">
|
|
<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>
|
|
<LogoOverlay
|
|
v-if="template.logos?.length"
|
|
:logos="template.logos"
|
|
:get-logo-url="workflowTemplatesStore.getLogoUrl"
|
|
/>
|
|
<ProgressSpinner
|
|
v-if="loadingTemplate === template.name"
|
|
class="absolute inset-0 z-10 m-auto size-12"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<template #bottom-right>
|
|
<template v-if="template.tags && template.tags.length > 0">
|
|
<Tag
|
|
v-for="tag in template.tags"
|
|
:key="tag"
|
|
:label="tag"
|
|
shape="overlay"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</CardTop>
|
|
</template>
|
|
<template #bottom>
|
|
<CardBottom>
|
|
<div class="flex flex-col gap-2 pt-3">
|
|
<h3
|
|
class="m-0 line-clamp-1 text-sm"
|
|
:title="
|
|
getTemplateTitle(
|
|
template,
|
|
getEffectiveSourceModule(template)
|
|
)
|
|
"
|
|
>
|
|
{{
|
|
getTemplateTitle(
|
|
template,
|
|
getEffectiveSourceModule(template)
|
|
)
|
|
}}
|
|
</h3>
|
|
<div class="flex justify-between gap-2">
|
|
<div class="flex-1">
|
|
<p
|
|
class="m-0 line-clamp-2 text-sm text-muted"
|
|
:title="getTemplateDescription(template)"
|
|
>
|
|
{{ getTemplateDescription(template) }}
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-if="template.tutorialUrl"
|
|
class="flex flex-col-reverse justify-center"
|
|
>
|
|
<Button
|
|
v-if="hoveredTemplate === template.name"
|
|
v-tooltip.bottom="$t('g.seeTutorial')"
|
|
v-bind="$attrs"
|
|
variant="inverted"
|
|
size="icon"
|
|
@click.stop="openTutorial(template)"
|
|
>
|
|
<i class="icon-[lucide--info] size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div class="flex">
|
|
<span
|
|
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
|
|
>
|
|
<template v-if="isAppTemplate(template)">
|
|
<i class="icon-[lucide--panels-top-left]" />
|
|
{{ $t('builderToolbar.app', 'App') }}
|
|
</template>
|
|
<template v-else>
|
|
<i class="icon-[lucide--workflow]" />
|
|
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
|
|
</template>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardBottom>
|
|
</template>
|
|
</CardContainer>
|
|
|
|
<!-- Loading More Skeletons -->
|
|
<CardContainer
|
|
v-for="n in isLoadingMore ? 6 : 0"
|
|
:key="`skeleton-${n}`"
|
|
size="compact"
|
|
variant="ghost"
|
|
rounded="lg"
|
|
class="hover:bg-base-background"
|
|
>
|
|
<template #top>
|
|
<CardTop ratio="square">
|
|
<template #default>
|
|
<div class="size-full animate-pulse bg-dialog-surface"></div>
|
|
</template>
|
|
</CardTop>
|
|
</template>
|
|
<template #bottom>
|
|
<CardBottom>
|
|
<div class="px-4 py-3">
|
|
<div
|
|
class="mb-2 h-6 animate-pulse rounded-sm bg-dialog-surface"
|
|
></div>
|
|
<div
|
|
class="h-4 animate-pulse rounded-sm bg-dialog-surface"
|
|
></div>
|
|
</div>
|
|
</CardBottom>
|
|
</template>
|
|
</CardContainer>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load More Trigger -->
|
|
<div
|
|
v-if="!isLoading && hasMoreTemplates"
|
|
ref="loadTrigger"
|
|
class="mt-4 flex h-4 w-full items-center justify-center"
|
|
>
|
|
<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-muted">
|
|
{{
|
|
$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, onMounted, provide, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import CardBottom from '@/components/card/CardBottom.vue'
|
|
import CardContainer from '@/components/card/CardContainer.vue'
|
|
import CardTop from '@/components/card/CardTop.vue'
|
|
import Tag from '@/components/chip/Tag.vue'
|
|
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
|
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
|
import SingleSelect from '@/components/ui/single-select/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 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'
|
|
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
|
import { useLazyPagination } from '@/composables/useLazyPagination'
|
|
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
|
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
import { useTelemetry } from '@/platform/telemetry'
|
|
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
|
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
|
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
|
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
|
import { OnCloseKey } from '@/types/widgetTypes'
|
|
import { createGridStyle } from '@/utils/gridUtil'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const { onClose: originalOnClose, initialCategory = 'all' } = defineProps<{
|
|
onClose: () => void
|
|
initialCategory?: string
|
|
}>()
|
|
|
|
// Track session time for telemetry
|
|
const sessionStartTime = ref<number>(0)
|
|
const templateWasSelected = ref(false)
|
|
|
|
onMounted(() => {
|
|
sessionStartTime.value = Date.now()
|
|
})
|
|
|
|
// Wrap onClose to track session end
|
|
const onClose = () => {
|
|
if (isCloud) {
|
|
const timeSpentSeconds = Math.floor(
|
|
(Date.now() - sessionStartTime.value) / 1000
|
|
)
|
|
|
|
useTelemetry()?.trackTemplateLibraryClosed({
|
|
template_selected: templateWasSelected.value,
|
|
time_spent_seconds: timeSpentSeconds
|
|
})
|
|
}
|
|
|
|
originalOnClose()
|
|
}
|
|
|
|
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 isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
|
|
|
|
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
|
|
})
|
|
|
|
// Navigation
|
|
const selectedNavItem = ref<string | null>(initialCategory)
|
|
|
|
// Filter templates based on selected navigation item
|
|
const navigationFilteredTemplates = computed(() => {
|
|
if (!selectedNavItem.value) {
|
|
return allTemplates.value
|
|
}
|
|
|
|
return workflowTemplatesStore.filterTemplatesByCategory(selectedNavItem.value)
|
|
})
|
|
|
|
// Template filtering with scope awareness
|
|
const {
|
|
searchQuery,
|
|
selectedModels,
|
|
selectedUseCases,
|
|
selectedRunsOn,
|
|
sortBy,
|
|
activeModels,
|
|
activeUseCases,
|
|
filteredTemplates,
|
|
availableModels,
|
|
availableUseCases,
|
|
availableRunsOn,
|
|
filteredCount,
|
|
totalCount,
|
|
resetFilters,
|
|
loadFuseOptions
|
|
} = useTemplateFiltering(navigationFilteredTemplates)
|
|
|
|
/**
|
|
* Raw search input bound to the search box. The actual `searchQuery` consumed
|
|
* by the filtering composable is only updated via `applySearchQuery` after the
|
|
* debounce settles, keeping Fuse/grid re-renders off the keystroke critical path.
|
|
*/
|
|
const searchInput = ref(searchQuery.value)
|
|
|
|
const applySearchQuery = async (query: string) => {
|
|
searchQuery.value = query
|
|
}
|
|
|
|
/**
|
|
* Sync the visible search input when `searchQuery` is reset externally
|
|
* (e.g. via the "Clear Filters" button).
|
|
*/
|
|
watch(searchQuery, (value) => {
|
|
if (value !== searchInput.value) searchInput.value = value
|
|
})
|
|
|
|
/**
|
|
* Coordinates state between the selected navigation item and the sort order to
|
|
* create deterministic, predictable behavior.
|
|
* @param source The origin of the change ('nav' or 'sort').
|
|
*/
|
|
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
|
const isPopularNav = selectedNavItem.value === 'popular'
|
|
const isPopularSort = sortBy.value === 'popular'
|
|
|
|
if (source === 'nav') {
|
|
if (isPopularNav && !isPopularSort) {
|
|
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
|
|
sortBy.value = 'popular'
|
|
} else if (!isPopularNav && isPopularSort) {
|
|
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
|
|
sortBy.value = 'default'
|
|
}
|
|
} else if (source === 'sort') {
|
|
// When sort is changed away from 'Popular' while in the 'Popular' category,
|
|
// reset the category to 'All Templates' to avoid a confusing state.
|
|
if (isPopularNav && !isPopularSort) {
|
|
selectedNavItem.value = 'all'
|
|
}
|
|
}
|
|
}
|
|
|
|
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
|
|
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
|
|
watch(sortBy, () => coordinateNavAndSort('sort'))
|
|
|
|
// Convert between string array and object array for MultiSelect component
|
|
// Only show selected items that exist in the current scope
|
|
const selectedModelObjects = computed({
|
|
get() {
|
|
// Only include selected models that exist in availableModels
|
|
return activeModels.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 activeUseCases.value.map((useCase) => ({
|
|
name: useCase,
|
|
value: useCase
|
|
}))
|
|
},
|
|
set(value: { name: string; value: string }[]) {
|
|
selectedUseCases.value = value.map((item) => item.value)
|
|
}
|
|
})
|
|
|
|
const selectedRunsOnObjects = computed({
|
|
get() {
|
|
return selectedRunsOn.value.map((runsOn) => ({
|
|
name: runsOn,
|
|
value: runsOn
|
|
}))
|
|
},
|
|
set(value: { name: string; value: string }[]) {
|
|
selectedRunsOn.value = value.map((item) => item.value)
|
|
}
|
|
})
|
|
|
|
// Loading states
|
|
const loadingTemplate = ref<string | null>(null)
|
|
const hoveredTemplate = ref<string | null>(null)
|
|
const cardRefs = ref<HTMLElement[]>([])
|
|
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
|
const selectContentStyle = primeVueOverlay.contentStyle
|
|
|
|
// Force re-render key for templates when sorting changes
|
|
const templateListKey = ref(0)
|
|
|
|
// 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 runsOnOptions = computed(() =>
|
|
availableRunsOn.value.map((runsOn) => ({
|
|
name: runsOn,
|
|
value: runsOn
|
|
}))
|
|
)
|
|
|
|
// 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 runsOnFilterLabel = computed(() => {
|
|
if (selectedRunsOnObjects.value.length === 0) {
|
|
return t('templateWorkflows.runsOnFilter', 'Runs On')
|
|
} else if (selectedRunsOnObjects.value.length === 1) {
|
|
return selectedRunsOnObjects.value[0].name
|
|
} else {
|
|
return t('templateWorkflows.runsOnSelected', {
|
|
count: selectedRunsOnObjects.value.length
|
|
})
|
|
}
|
|
})
|
|
|
|
// Sort options
|
|
const sortOptions = computed(() => [
|
|
{
|
|
name: t('templateWorkflows.sort.default', 'Default'),
|
|
value: 'default'
|
|
},
|
|
{
|
|
name: t('templateWorkflows.sort.recommended', 'Recommended'),
|
|
value: 'recommended'
|
|
},
|
|
{
|
|
name: t('templateWorkflows.sort.popular', 'Popular'),
|
|
value: 'popular'
|
|
},
|
|
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
|
{
|
|
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (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(
|
|
[
|
|
filteredTemplates,
|
|
selectedNavItem,
|
|
sortBy,
|
|
selectedModels,
|
|
selectedUseCases,
|
|
selectedRunsOn
|
|
],
|
|
() => {
|
|
resetPagination()
|
|
// Clear loading state and force re-render of template list
|
|
loadingTemplate.value = null
|
|
templateListKey.value++
|
|
}
|
|
)
|
|
|
|
// Methods
|
|
const onLoadWorkflow = async (template: TemplateInfo) => {
|
|
loadingTemplate.value = template.name
|
|
try {
|
|
await loadWorkflowTemplate(
|
|
template.name,
|
|
getEffectiveSourceModule(template)
|
|
)
|
|
templateWasSelected.value = true
|
|
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 () => {
|
|
await Promise.all([
|
|
loadTemplates(),
|
|
workflowTemplatesStore.loadWorkflowTemplates(),
|
|
loadFuseOptions()
|
|
])
|
|
return true
|
|
},
|
|
false, // initial state
|
|
{
|
|
immediate: true // Start loading immediately
|
|
}
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
cardRefs.value = [] // Release DOM refs
|
|
})
|
|
</script>
|