mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-31 21:39:54 +00:00
## Summary
Implements a comprehensive media asset card component system for the
Asset Manager sidebar, enabling display and interaction with various
media types (images, videos, audio, and 3D models).
## Changes
### New Components
- **MediaAssetCard**: Main card component for displaying media assets
- **Media type-specific components**: Specialized display logic for each
media type
- MediaImageTop/Bottom
- MediaVideoTop/Bottom
- MediaAudioTop/Bottom
- Media3DTop/Bottom
- **MediaAssetActions**: Top-left action buttons (delete, download, more
options)
- **MediaAssetMoreMenu**: Dropdown menu for additional actions
- **SquareChip**: Chip component for displaying duration and file format
with dark/light variants
- **MediaAssetButtonDivider**: Visual separator for button groups
### Features
- **Video playback**: Autoplay with native video controls
- Dynamic duration chip positioning based on control visibility
- Hides overlays when video is playing
- **Audio playback**: Audio icon with HTML5 audio element
- Duration chip with consistent positioning
- **3D model support**: Icon display for 3D assets
- **Selection state**: Proper hover and selected state handling with CSS
priority fixes
### Architecture Improvements
- **Domain-Driven Design structure**: Organized under
`src/platform/mediaAsset/` following DDD principles
- **Provide/Inject pattern**: Eliminates props drilling with
MediaAssetKey InjectionKey
- **Composable pattern**: `useMediaAssetActions` manages all action
handlers
- **Type safety**: Comprehensive TypeScript types for media assets and
actions
### UI/UX Enhancements
- **CardTop component**: Added custom class props for slot positioning
- **SquareChip component**: Backdrop blur effects with variant system
- **Lazy loading**: Image optimization with LazyImage component
- **Responsive states**: Loading, selected, and hover states
### Utilities
- **formatDuration**: Converts milliseconds to human-readable format
(45s, 1m 23s, 1h 2m)
## Testing
- Comprehensive Storybook stories for all media types
- Grid layout examples
- Loading and selected state demonstrations
## File Structure
```
src/platform/assets/
├── components/
│ ├── MediaAssetCard.vue
│ ├── MediaAssetCard.stories.ts
│ ├── MediaAssetActions.vue
│ ├── MediaAssetMoreMenu.vue
│ ├── MediaAssetButtonDivider.vue
│ ├── MediaImageTop.vue
│ ├── MediaImageBottom.vue
│ ├── MediaVideoTop.vue
│ ├── MediaVideoBottom.vue
│ ├── MediaAudioTop.vue
│ ├── MediaAudioBottom.vue
│ ├── Media3DTop.vue
│ └── Media3DBottom.vue
├── composables/
│ └── useMediaAssetActions.ts
└── schemas/
└── mediaAssetSchema.ts
```
## Screenshots
[media_asset_record.webm](https://github.com/user-attachments/assets/d13b5cc0-a262-4850-bb81-ca1daa0dd969)
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
760 lines
23 KiB
Vue
760 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" size="lg" 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 class="icon-[lucide--filter-x]" />
|
|
</template>
|
|
</IconTextButton>
|
|
</div>
|
|
</template>
|
|
|
|
<template #contentFilter>
|
|
<div class="relative flex flex-wrap gap-2 px-6 pt-2 pb-4">
|
|
<!-- 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 class="icon-[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 class="icon-[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 class="icon-[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="w-62.5"
|
|
>
|
|
<template #icon>
|
|
<i class="icon-[lucide--arrow-up-down]" />
|
|
</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] h-12 w-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 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-white dark-theme:hover:bg-zinc-800"
|
|
>
|
|
<template #top>
|
|
<CardTop ratio="landscape">
|
|
<template #default>
|
|
<div
|
|
class="h-full w-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 bg-dialog-surface"
|
|
></div>
|
|
<div
|
|
class="h-4 animate-pulse rounded bg-dialog-surface"
|
|
></div>
|
|
</div>
|
|
</CardBottom>
|
|
</template>
|
|
</CardContainer>
|
|
|
|
<!-- Actual Template Cards -->
|
|
<CardContainer
|
|
v-for="template in isLoading ? [] : displayTemplates"
|
|
:key="template.name"
|
|
ref="cardRefs"
|
|
size="compact"
|
|
variant="ghost"
|
|
rounded="lg"
|
|
:data-testid="`template-workflow-${template.name}`"
|
|
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
|
@mouseenter="hoveredTemplate = template.name"
|
|
@mouseleave="hoveredTemplate = null"
|
|
@click="onLoadWorkflow(template)"
|
|
>
|
|
<template #top>
|
|
<CardTop ratio="square">
|
|
<template #default>
|
|
<!-- Template Thumbnail -->
|
|
<div
|
|
class="relative h-full w-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>
|
|
<ProgressSpinner
|
|
v-if="loadingTemplate === template.name"
|
|
class="absolute inset-0 z-10 m-auto h-12 w-12"
|
|
/>
|
|
</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="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"
|
|
>
|
|
<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}`"
|
|
size="compact"
|
|
variant="ghost"
|
|
rounded="lg"
|
|
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
|
>
|
|
<template #top>
|
|
<CardTop ratio="square">
|
|
<template #default>
|
|
<div
|
|
class="h-full w-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 bg-dialog-surface"
|
|
></div>
|
|
<div
|
|
class="h-4 animate-pulse rounded 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-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 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(
|
|
[
|
|
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>
|