mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
12 Commits
drjkl/remo
...
filter-tem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7840b1c05c | ||
|
|
6316dde209 | ||
|
|
fbc44b31be | ||
|
|
650e9d0710 | ||
|
|
cc67ee035d | ||
|
|
e46f682da3 | ||
|
|
bfdad0e475 | ||
|
|
7e6a3cd4ff | ||
|
|
d6074cd9ee | ||
|
|
6b69225bbf | ||
|
|
0b2d985fbf | ||
|
|
939b94f85f |
124
src/components/common/LazyImage.vue
Normal file
124
src/components/common/LazyImage.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative overflow-hidden w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<Skeleton
|
||||
v-if="!isImageLoaded"
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<img
|
||||
v-show="isImageLoaded"
|
||||
ref="imageRef"
|
||||
:src="cachedSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
:class="imageClass"
|
||||
:style="imageStyle"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div
|
||||
v-if="hasError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
|
||||
>
|
||||
<i class="pi pi-image text-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
|
||||
const {
|
||||
src,
|
||||
alt = '',
|
||||
imageClass = '',
|
||||
imageStyle,
|
||||
rootMargin = '300px'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
imageClass?: string | string[] | Record<string, boolean>
|
||||
imageStyle?: Record<string, any>
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const isIntersecting = ref(false)
|
||||
const isImageLoaded = ref(false)
|
||||
const hasError = ref(false)
|
||||
const cachedSrc = ref<string | undefined>(undefined)
|
||||
|
||||
const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
|
||||
|
||||
// Use intersection observer to detect when the image container comes into view
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
isIntersecting.value = entry?.isIntersecting ?? false
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
threshold: 0.1
|
||||
}
|
||||
)
|
||||
|
||||
// Only start loading the image when it's in view
|
||||
const shouldLoad = computed(() => isIntersecting.value)
|
||||
|
||||
watch(
|
||||
shouldLoad,
|
||||
async (shouldLoad) => {
|
||||
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
|
||||
try {
|
||||
const cachedMedia = await getCachedMedia(src)
|
||||
if (cachedMedia.error) {
|
||||
hasError.value = true
|
||||
} else if (cachedMedia.objectUrl) {
|
||||
const acquiredUrl = acquireUrl(src)
|
||||
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
|
||||
} else {
|
||||
cachedSrc.value = src
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load cached media:', error)
|
||||
cachedSrc.value = src
|
||||
}
|
||||
} else if (!shouldLoad) {
|
||||
if (cachedSrc.value?.startsWith('blob:')) {
|
||||
releaseUrl(src)
|
||||
}
|
||||
// Hide image when out of view
|
||||
isImageLoaded.value = false
|
||||
cachedSrc.value = undefined
|
||||
hasError.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const onImageLoad = () => {
|
||||
isImageLoaded.value = true
|
||||
hasError.value = false
|
||||
}
|
||||
|
||||
const onImageError = () => {
|
||||
hasError.value = true
|
||||
isImageLoaded.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cachedSrc.value?.startsWith('blob:')) {
|
||||
releaseUrl(src)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
195
src/components/templates/TemplateSearchBar.vue
Normal file
195
src/components/templates/TemplateSearchBar.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<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">
|
||||
<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'
|
||||
}
|
||||
}"
|
||||
: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' }
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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'
|
||||
|
||||
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' })
|
||||
|
||||
// emit removed - no longer needed since clearFilters was removed
|
||||
|
||||
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]
|
||||
}>()
|
||||
|
||||
30
src/components/templates/TemplateWorkflowCardSkeleton.vue
Normal file
30
src/components/templates/TemplateWorkflowCardSkeleton.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<Card
|
||||
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
|
||||
:pt="{
|
||||
body: { class: 'p-0 h-full flex flex-col' }
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative overflow-hidden rounded-t-lg">
|
||||
<Skeleton width="16rem" height="12rem" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center px-4 py-3">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<Skeleton width="80%" height="1.25rem" class="mb-2" />
|
||||
<Skeleton width="100%" height="0.875rem" class="mb-1" />
|
||||
<Skeleton width="90%" height="0.875rem" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from 'primevue/card'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
@@ -28,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: `
|
||||
@@ -53,10 +63,63 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-search-bar"></div>',
|
||||
props: [
|
||||
'searchQuery',
|
||||
'filteredCount',
|
||||
'availableModels',
|
||||
'selectedModels',
|
||||
'sortBy'
|
||||
],
|
||||
emits: [
|
||||
'update:searchQuery',
|
||||
'update:selectedModels',
|
||||
'update:sortBy',
|
||||
'clearFilters'
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-skeleton"></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useLocalStorage: () => 'grid'
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useIntersectionObserver', () => ({
|
||||
useIntersectionObserver: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLazyPagination', () => ({
|
||||
useLazyPagination: (items: any) => ({
|
||||
paginatedItems: items,
|
||||
isLoading: { value: false },
|
||||
hasMoreItems: { value: false },
|
||||
loadNextPage: vi.fn(),
|
||||
reset: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
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 },
|
||||
resetFilters: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowView', () => {
|
||||
const createTemplate = (name: string): TemplateInfo => ({
|
||||
name,
|
||||
@@ -67,6 +130,18 @@ describe('TemplateWorkflowView', () => {
|
||||
})
|
||||
|
||||
const mountView = (props = {}) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templateWorkflows: {
|
||||
loadingMore: 'Loading more...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mount(TemplateWorkflowView, {
|
||||
props: {
|
||||
title: 'Test Templates',
|
||||
@@ -78,7 +153,12 @@ describe('TemplateWorkflowView', () => {
|
||||
createTemplate('template-3')
|
||||
],
|
||||
loading: null,
|
||||
availableSubcategories: [],
|
||||
selectedSubcategory: null,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
<template>
|
||||
<DataView
|
||||
:value="templates"
|
||||
:value="finalDisplayTemplates"
|
||||
:layout="layout"
|
||||
data-key="name"
|
||||
:lazy="true"
|
||||
pt:root="h-full grid grid-rows-[auto_1fr]"
|
||||
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
|
||||
pt:content="p-2 overflow-auto"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg">{{ title }}</h2>
|
||||
<SelectButton
|
||||
v-model="layout"
|
||||
:options="['grid', 'list']"
|
||||
:allow-empty="false"
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg">{{ title }}</h2>
|
||||
<SelectButton
|
||||
v-model="layout"
|
||||
:options="['grid', 'list']"
|
||||
:allow-empty="false"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
|
||||
<!-- Subcategory Navigation -->
|
||||
<div
|
||||
v-if="availableSubcategories.length > 0"
|
||||
class="mb-4 overflow-x-scroll flex max-w-[1000px]"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
|
||||
</template>
|
||||
</SelectButton>
|
||||
<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>
|
||||
|
||||
@@ -33,18 +66,35 @@
|
||||
</template>
|
||||
|
||||
<template #grid="{ items }">
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
|
||||
>
|
||||
<TemplateWorkflowCard
|
||||
v-for="template in items"
|
||||
:key="template.name"
|
||||
:source-module="sourceModule"
|
||||
:template="template"
|
||||
:loading="loading === template.name"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
|
||||
>
|
||||
<TemplateWorkflowCard
|
||||
v-for="template in items"
|
||||
:key="template.name"
|
||||
:source-module="sourceModule"
|
||||
:template="template"
|
||||
:loading="loading === template.name"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
<TemplateWorkflowCardSkeleton
|
||||
v-for="n in shouldUsePagination && isLoadingMore
|
||||
? skeletonCount
|
||||
: 0"
|
||||
:key="`skeleton-${n}`"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldUsePagination && hasMoreTemplates"
|
||||
ref="loadTrigger"
|
||||
class="w-full h-4 flex justify-center items-center"
|
||||
>
|
||||
<div v-if="isLoadingMore" class="text-sm text-muted">
|
||||
{{ t('templateWorkflows.loadingMore') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataView>
|
||||
@@ -52,19 +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 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 type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
defineProps<{
|
||||
const { t } = useI18n()
|
||||
|
||||
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'>(
|
||||
@@ -72,8 +140,59 @@ const layout = useLocalStorage<'grid' | 'list'>(
|
||||
'grid'
|
||||
)
|
||||
|
||||
const skeletonCount = 6
|
||||
const loadTrigger = ref<HTMLElement | null>(null)
|
||||
|
||||
// Since filtering is now handled at parent level, we just use the templates directly
|
||||
const displayTemplates = computed(() => templates || [])
|
||||
|
||||
// Simplified pagination - only show pagination when we have lots of templates
|
||||
const shouldUsePagination = computed(() => templates.length > 12)
|
||||
|
||||
// Lazy pagination setup using templates directly
|
||||
const {
|
||||
paginatedItems: paginatedTemplates,
|
||||
isLoading: isLoadingMore,
|
||||
hasMoreItems: hasMoreTemplates,
|
||||
loadNextPage,
|
||||
reset: resetPagination
|
||||
} = useLazyPagination(displayTemplates, {
|
||||
itemsPerPage: 12
|
||||
})
|
||||
|
||||
// Final templates to display with pagination
|
||||
const finalDisplayTemplates = computed(() => {
|
||||
return shouldUsePagination.value
|
||||
? paginatedTemplates.value
|
||||
: displayTemplates.value
|
||||
})
|
||||
// Intersection observer for auto-loading (only when not searching)
|
||||
useIntersectionObserver(
|
||||
loadTrigger,
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (
|
||||
entry?.isIntersecting &&
|
||||
shouldUsePagination.value &&
|
||||
hasMoreTemplates.value &&
|
||||
!isLoadingMore.value
|
||||
) {
|
||||
void loadNextPage()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '200px',
|
||||
threshold: 0.1
|
||||
}
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
@@ -12,6 +12,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMouseInElement: () => ({
|
||||
elementX: ref(50),
|
||||
@@ -35,23 +44,24 @@ describe('CompareSliderThumbnail', () => {
|
||||
|
||||
it('renders both base and overlay images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe('/base-image.jpg')
|
||||
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages.length).toBe(2)
|
||||
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
|
||||
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
|
||||
})
|
||||
|
||||
it('applies correct alt text to both images', () => {
|
||||
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
|
||||
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
|
||||
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
|
||||
})
|
||||
|
||||
it('applies clip-path style to overlay image', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const overlay = wrapper.findAll('img')[1]
|
||||
expect(overlay.attributes('style')).toContain('clip-path')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageStyle = overlayLazyImage.props('imageStyle')
|
||||
expect(imageStyle.clipPath).toContain('inset')
|
||||
})
|
||||
|
||||
it('renders slider divider', () => {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<img
|
||||
<LazyImage
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
:class="
|
||||
:image-class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<div ref="containerRef" class="absolute inset-0">
|
||||
<img
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
:class="
|
||||
:image-class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
:style="{
|
||||
:image-style="{
|
||||
clipPath: `inset(0 ${100 - sliderPosition}% 0 0)`
|
||||
}"
|
||||
/>
|
||||
@@ -36,6 +36,7 @@
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const SLIDER_START_POSITION = 50
|
||||
|
||||
@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('DefaultThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(DefaultThumbnail, {
|
||||
@@ -25,9 +34,9 @@ describe('DefaultThumbnail', () => {
|
||||
|
||||
it('renders image with correct src and alt', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('/test-image.jpg')
|
||||
expect(img.attributes('alt')).toBe('Test Image')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
expect(lazyImage.props('src')).toBe('/test-image.jpg')
|
||||
expect(lazyImage.props('alt')).toBe('Test Image')
|
||||
})
|
||||
|
||||
it('applies scale transform when hovered', () => {
|
||||
@@ -35,35 +44,43 @@ describe('DefaultThumbnail', () => {
|
||||
isHovered: true,
|
||||
hoverZoom: 10
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('style')).toContain('scale(1.1)')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
|
||||
})
|
||||
|
||||
it('does not apply scale transform when not hovered', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isHovered: false
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('style')).toBeUndefined()
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
expect(lazyImage.props('imageStyle')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies video styling for video type', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isVideo: true
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('w-full')
|
||||
expect(img.classes()).toContain('h-full')
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('w-full')
|
||||
expect(classString).toContain('h-full')
|
||||
expect(classString).toContain('object-cover')
|
||||
})
|
||||
|
||||
it('applies image styling for non-video type', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isVideo: false
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('max-w-full')
|
||||
expect(img.classes()).toContain('object-contain')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('max-w-full')
|
||||
expect(classString).toContain('object-contain')
|
||||
})
|
||||
|
||||
it('applies correct styling for webp images', () => {
|
||||
@@ -71,8 +88,12 @@ describe('DefaultThumbnail', () => {
|
||||
src: '/test-video.webp',
|
||||
isVideo: true
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('object-cover')
|
||||
})
|
||||
|
||||
it('image is not draggable', () => {
|
||||
@@ -83,11 +104,15 @@ describe('DefaultThumbnail', () => {
|
||||
|
||||
it('applies transition classes', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('transform-gpu')
|
||||
expect(img.classes()).toContain('transition-transform')
|
||||
expect(img.classes()).toContain('duration-300')
|
||||
expect(img.classes()).toContain('ease-out')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('transform-gpu')
|
||||
expect(classString).toContain('transition-transform')
|
||||
expect(classString).toContain('duration-300')
|
||||
expect(classString).toContain('ease-out')
|
||||
})
|
||||
|
||||
it('passes correct props to BaseThumbnail', () => {
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
<template>
|
||||
<BaseThumbnail :hover-zoom="hoverZoom" :is-hovered="isHovered">
|
||||
<div class="overflow-hidden w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
:class="[
|
||||
'transform-gpu transition-transform duration-300 ease-out',
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
]"
|
||||
:style="
|
||||
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<LazyImage
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:image-class="[
|
||||
'transform-gpu transition-transform duration-300 ease-out',
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
]"
|
||||
:image-style="
|
||||
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
|
||||
"
|
||||
/>
|
||||
</BaseThumbnail>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const { src, isVideo } = defineProps<{
|
||||
|
||||
@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('HoverDissolveThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(HoverDissolveThumbnail, {
|
||||
@@ -27,31 +36,39 @@ describe('HoverDissolveThumbnail', () => {
|
||||
|
||||
it('renders both base and overlay images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe('/base-image.jpg')
|
||||
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages.length).toBe(2)
|
||||
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
|
||||
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
|
||||
})
|
||||
|
||||
it('applies correct alt text to both images', () => {
|
||||
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
|
||||
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
|
||||
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
|
||||
})
|
||||
|
||||
it('makes overlay image visible when hovered', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const overlayImage = wrapper.findAll('img')[1]
|
||||
expect(overlayImage.classes()).toContain('opacity-100')
|
||||
expect(overlayImage.classes()).not.toContain('opacity-0')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('opacity-100')
|
||||
expect(classString).not.toContain('opacity-0')
|
||||
})
|
||||
|
||||
it('makes overlay image hidden when not hovered', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: false })
|
||||
const overlayImage = wrapper.findAll('img')[1]
|
||||
expect(overlayImage.classes()).toContain('opacity-0')
|
||||
expect(overlayImage.classes()).not.toContain('opacity-100')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('opacity-0')
|
||||
expect(classString).not.toContain('opacity-100')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
@@ -62,21 +79,33 @@ describe('HoverDissolveThumbnail', () => {
|
||||
|
||||
it('applies transition classes to overlay image', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const overlayImage = wrapper.findAll('img')[1]
|
||||
expect(overlayImage.classes()).toContain('transition-opacity')
|
||||
expect(overlayImage.classes()).toContain('duration-300')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('transition-opacity')
|
||||
expect(classString).toContain('duration-300')
|
||||
})
|
||||
|
||||
it('applies correct positioning to both images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
|
||||
// Check base image
|
||||
expect(images[0].classes()).toContain('absolute')
|
||||
expect(images[0].classes()).toContain('inset-0')
|
||||
const baseImageClass = lazyImages[0].props('imageClass')
|
||||
const baseClassString = Array.isArray(baseImageClass)
|
||||
? baseImageClass.join(' ')
|
||||
: baseImageClass
|
||||
expect(baseClassString).toContain('absolute')
|
||||
expect(baseClassString).toContain('inset-0')
|
||||
|
||||
// Check overlay image
|
||||
expect(images[1].classes()).toContain('absolute')
|
||||
expect(images[1].classes()).toContain('inset-0')
|
||||
const overlayImageClass = lazyImages[1].props('imageClass')
|
||||
const overlayClassString = Array.isArray(overlayImageClass)
|
||||
? overlayImageClass.join(' ')
|
||||
: overlayImageClass
|
||||
expect(overlayClassString).toContain('absolute')
|
||||
expect(overlayClassString).toContain('inset-0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
<template>
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<div class="relative w-full h-full">
|
||||
<img
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="absolute inset-0"
|
||||
:class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<img
|
||||
<LazyImage :src="baseImageSrc" :alt="alt" :image-class="baseImageClass" />
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="absolute inset-0 transition-opacity duration-300"
|
||||
:class="[
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain',
|
||||
{ 'opacity-100': isHovered, 'opacity-0': !isHovered }
|
||||
]"
|
||||
:image-class="overlayImageClass"
|
||||
/>
|
||||
</div>
|
||||
</BaseThumbnail>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const { baseImageSrc, overlayImageSrc, isVideo } = defineProps<{
|
||||
const { baseImageSrc, overlayImageSrc, isVideo, isHovered } = defineProps<{
|
||||
baseImageSrc: string
|
||||
overlayImageSrc: string
|
||||
alt: string
|
||||
@@ -44,4 +30,17 @@ const isVideoType =
|
||||
baseImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
false
|
||||
|
||||
const baseImageClass = computed(() => {
|
||||
return `absolute inset-0 ${isVideoType ? 'w-full h-full object-cover' : 'max-w-full max-h-64 object-contain'}`
|
||||
})
|
||||
|
||||
const overlayImageClass = computed(() => {
|
||||
const baseClasses = 'absolute inset-0 transition-opacity duration-300'
|
||||
const sizeClasses = isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
const opacityClasses = isHovered ? 'opacity-100' : 'opacity-0'
|
||||
return `${baseClasses} ${sizeClasses} ${opacityClasses}`
|
||||
})
|
||||
</script>
|
||||
|
||||
60
src/composables/useIntersectionObserver.ts
Normal file
60
src/composables/useIntersectionObserver.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { type Ref, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
export interface UseIntersectionObserverOptions
|
||||
extends IntersectionObserverInit {
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export function useIntersectionObserver(
|
||||
target: Ref<Element | null>,
|
||||
callback: IntersectionObserverCallback,
|
||||
options: UseIntersectionObserverOptions = {}
|
||||
) {
|
||||
const { immediate = true, ...observerOptions } = options
|
||||
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' && 'IntersectionObserver' in window
|
||||
const isIntersecting = ref(false)
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
}
|
||||
|
||||
const observe = () => {
|
||||
cleanup()
|
||||
|
||||
if (!isSupported || !target.value) return
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
isIntersecting.value = entries.some((entry) => entry.isIntersecting)
|
||||
callback(entries, observer!)
|
||||
}, observerOptions)
|
||||
|
||||
observer.observe(target.value)
|
||||
}
|
||||
|
||||
const unobserve = () => {
|
||||
if (observer && target.value) {
|
||||
observer.unobserve(target.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
watch(target, observe, { immediate: true, flush: 'post' })
|
||||
}
|
||||
|
||||
onBeforeUnmount(cleanup)
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isIntersecting,
|
||||
observe,
|
||||
unobserve,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
107
src/composables/useLazyPagination.ts
Normal file
107
src/composables/useLazyPagination.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { type Ref, computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
export interface LazyPaginationOptions {
|
||||
itemsPerPage?: number
|
||||
initialPage?: number
|
||||
}
|
||||
|
||||
export function useLazyPagination<T>(
|
||||
items: Ref<T[]> | T[],
|
||||
options: LazyPaginationOptions = {}
|
||||
) {
|
||||
const { itemsPerPage = 12, initialPage = 1 } = options
|
||||
|
||||
const currentPage = ref(initialPage)
|
||||
const isLoading = ref(false)
|
||||
const loadedPages = shallowRef(new Set<number>([]))
|
||||
|
||||
// Get reactive items array
|
||||
const itemsArray = computed(() => {
|
||||
const itemData = 'value' in items ? items.value : items
|
||||
return Array.isArray(itemData) ? itemData : []
|
||||
})
|
||||
|
||||
// Simulate pagination by slicing the items
|
||||
const paginatedItems = computed(() => {
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const loadedPageNumbers = Array.from(loadedPages.value).sort(
|
||||
(a, b) => a - b
|
||||
)
|
||||
const maxLoadedPage = Math.max(...loadedPageNumbers, 0)
|
||||
const endIndex = maxLoadedPage * itemsPerPage
|
||||
return itemData.slice(0, endIndex)
|
||||
})
|
||||
|
||||
const hasMoreItems = computed(() => {
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const loadedPagesArray = Array.from(loadedPages.value)
|
||||
const maxLoadedPage = Math.max(...loadedPagesArray, 0)
|
||||
return maxLoadedPage * itemsPerPage < itemData.length
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.ceil(itemData.length / itemsPerPage)
|
||||
})
|
||||
|
||||
const loadNextPage = async () => {
|
||||
if (isLoading.value || !hasMoreItems.value) return
|
||||
|
||||
isLoading.value = true
|
||||
const loadedPagesArray = Array.from(loadedPages.value)
|
||||
const nextPage = Math.max(...loadedPagesArray, 0) + 1
|
||||
|
||||
// Simulate network delay
|
||||
// await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
|
||||
const newLoadedPages = new Set(loadedPages.value)
|
||||
newLoadedPages.add(nextPage)
|
||||
loadedPages.value = newLoadedPages
|
||||
currentPage.value = nextPage
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// Initialize with first page
|
||||
watch(
|
||||
() => itemsArray.value.length,
|
||||
(length) => {
|
||||
if (length > 0 && loadedPages.value.size === 0) {
|
||||
loadedPages.value = new Set([1])
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
currentPage.value = initialPage
|
||||
loadedPages.value = new Set([])
|
||||
isLoading.value = false
|
||||
|
||||
// Immediately load first page if we have items
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length > 0) {
|
||||
loadedPages.value = new Set([1])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
paginatedItems,
|
||||
isLoading,
|
||||
hasMoreItems,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loadNextPage,
|
||||
reset
|
||||
}
|
||||
}
|
||||
134
src/composables/useTemplateFiltering.ts
Normal file
134
src/composables/useTemplateFiltering.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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(() => {
|
||||
let templateData = templatesArray.value
|
||||
if (templateData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by subcategory
|
||||
if (selectedSubcategory.value) {
|
||||
templateData = templateData.filter((template) =>
|
||||
template.tags?.includes(selectedSubcategory.value!)
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
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
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"confirmed": "Confirmed",
|
||||
"reset": "Reset",
|
||||
"resetAll": "Reset All",
|
||||
"clearFilters": "Clear Filters",
|
||||
"resetAllKeybindingsTooltip": "Reset all keybindings to default",
|
||||
"customizeFolder": "Customize Folder",
|
||||
"icon": "Icon",
|
||||
@@ -549,6 +550,17 @@
|
||||
},
|
||||
"templateWorkflows": {
|
||||
"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",
|
||||
@@ -566,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.",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "Categoría",
|
||||
"choose_file_to_upload": "elige archivo para subir",
|
||||
"clear": "Limpiar",
|
||||
"clearFilters": "Borrar filtros",
|
||||
"close": "Cerrar",
|
||||
"color": "Color",
|
||||
"comingSoon": "Próximamente",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "Video",
|
||||
"Video API": "API de Video"
|
||||
},
|
||||
"loadingMore": "Cargando más plantillas...",
|
||||
"searchPlaceholder": "Buscar plantillas...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "Catégorie",
|
||||
"choose_file_to_upload": "choisissez le fichier à télécharger",
|
||||
"clear": "Effacer",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"close": "Fermer",
|
||||
"color": "Couleur",
|
||||
"comingSoon": "Bientôt disponible",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "Vidéo",
|
||||
"Video API": "API vidéo"
|
||||
},
|
||||
"loadingMore": "Chargement de plus de modèles...",
|
||||
"searchPlaceholder": "Rechercher des modèles...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "カテゴリ",
|
||||
"choose_file_to_upload": "アップロードするファイルを選択",
|
||||
"clear": "クリア",
|
||||
"clearFilters": "フィルターをクリア",
|
||||
"close": "閉じる",
|
||||
"color": "色",
|
||||
"comingSoon": "近日公開",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "ビデオ",
|
||||
"Video API": "動画API"
|
||||
},
|
||||
"loadingMore": "さらにテンプレートを読み込み中...",
|
||||
"searchPlaceholder": "テンプレートを検索...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "카테고리",
|
||||
"choose_file_to_upload": "업로드할 파일 선택",
|
||||
"clear": "지우기",
|
||||
"clearFilters": "필터 지우기",
|
||||
"close": "닫기",
|
||||
"color": "색상",
|
||||
"comingSoon": "곧 출시 예정",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "비디오",
|
||||
"Video API": "비디오 API"
|
||||
},
|
||||
"loadingMore": "템플릿을 더 불러오는 중...",
|
||||
"searchPlaceholder": "템플릿 검색...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "Категория",
|
||||
"choose_file_to_upload": "выберите файл для загрузки",
|
||||
"clear": "Очистить",
|
||||
"clearFilters": "Сбросить фильтры",
|
||||
"close": "Закрыть",
|
||||
"color": "Цвет",
|
||||
"comingSoon": "Скоро будет",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "Видео",
|
||||
"Video API": "Video API"
|
||||
},
|
||||
"loadingMore": "Загрузка дополнительных шаблонов...",
|
||||
"searchPlaceholder": "Поиск шаблонов...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "分類",
|
||||
"choose_file_to_upload": "選擇要上傳的檔案",
|
||||
"clear": "清除",
|
||||
"clearFilters": "清除篩選",
|
||||
"close": "關閉",
|
||||
"color": "顏色",
|
||||
"comingSoon": "即將推出",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "影片",
|
||||
"Video API": "影片 API"
|
||||
},
|
||||
"loadingMore": "正在載入更多範本...",
|
||||
"searchPlaceholder": "搜尋範本...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "类别",
|
||||
"choose_file_to_upload": "选择要上传的文件",
|
||||
"clear": "清除",
|
||||
"clearFilters": "清除篩選",
|
||||
"close": "关闭",
|
||||
"color": "颜色",
|
||||
"comingSoon": "即将推出",
|
||||
@@ -1231,6 +1232,8 @@
|
||||
"Video": "视频生成",
|
||||
"Video API": "视频 API"
|
||||
},
|
||||
"loadingMore": "正在載入更多範本...",
|
||||
"searchPlaceholder": "搜尋範本...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "混元3D 2.0 图生模型",
|
||||
|
||||
226
src/services/mediaCacheService.ts
Normal file
226
src/services/mediaCacheService.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface CachedMedia {
|
||||
src: string
|
||||
blob?: Blob
|
||||
objectUrl?: string
|
||||
error?: boolean
|
||||
isLoading: boolean
|
||||
lastAccessed: number
|
||||
}
|
||||
|
||||
export interface MediaCacheOptions {
|
||||
maxSize?: number
|
||||
maxAge?: number // in milliseconds
|
||||
preloadDistance?: number // pixels from viewport
|
||||
}
|
||||
|
||||
class MediaCacheService {
|
||||
public cache = reactive(new Map<string, CachedMedia>())
|
||||
private readonly maxSize: number
|
||||
private readonly maxAge: number
|
||||
private cleanupInterval: number | null = null
|
||||
private urlRefCount = new Map<string, number>()
|
||||
|
||||
constructor(options: MediaCacheOptions = {}) {
|
||||
this.maxSize = options.maxSize ?? 100
|
||||
this.maxAge = options.maxAge ?? 30 * 60 * 1000 // 30 minutes
|
||||
|
||||
// Start cleanup interval
|
||||
this.startCleanupInterval()
|
||||
}
|
||||
|
||||
private startCleanupInterval() {
|
||||
// Clean up every 5 minutes
|
||||
this.cleanupInterval = window.setInterval(
|
||||
() => {
|
||||
this.cleanup()
|
||||
},
|
||||
5 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const now = Date.now()
|
||||
const keysToDelete: string[] = []
|
||||
|
||||
// Find expired entries
|
||||
for (const [key, entry] of Array.from(this.cache.entries())) {
|
||||
if (now - entry.lastAccessed > this.maxAge) {
|
||||
// Only revoke object URL if no components are using it
|
||||
if (entry.objectUrl) {
|
||||
const refCount = this.urlRefCount.get(entry.objectUrl) || 0
|
||||
if (refCount === 0) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
this.urlRefCount.delete(entry.objectUrl)
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
// Don't delete cache entry if URL is still in use
|
||||
} else {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove expired entries
|
||||
keysToDelete.forEach((key) => this.cache.delete(key))
|
||||
|
||||
// If still over size limit, remove oldest entries that aren't in use
|
||||
if (this.cache.size > this.maxSize) {
|
||||
const entries = Array.from(this.cache.entries())
|
||||
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
|
||||
|
||||
let removedCount = 0
|
||||
const targetRemoveCount = this.cache.size - this.maxSize
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
if (removedCount >= targetRemoveCount) break
|
||||
|
||||
if (entry.objectUrl) {
|
||||
const refCount = this.urlRefCount.get(entry.objectUrl) || 0
|
||||
if (refCount === 0) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
this.urlRefCount.delete(entry.objectUrl)
|
||||
this.cache.delete(key)
|
||||
removedCount++
|
||||
}
|
||||
} else {
|
||||
this.cache.delete(key)
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getCachedMedia(src: string): Promise<CachedMedia> {
|
||||
let entry = this.cache.get(src)
|
||||
|
||||
if (entry) {
|
||||
// Update last accessed time
|
||||
entry.lastAccessed = Date.now()
|
||||
return entry
|
||||
}
|
||||
|
||||
// Create new entry
|
||||
entry = {
|
||||
src,
|
||||
isLoading: true,
|
||||
lastAccessed: Date.now()
|
||||
}
|
||||
|
||||
// Update cache with loading entry
|
||||
this.cache.set(src, entry)
|
||||
|
||||
try {
|
||||
// Fetch the media
|
||||
const response = await fetch(src)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
// Update entry with successful result
|
||||
const updatedEntry: CachedMedia = {
|
||||
src,
|
||||
blob,
|
||||
objectUrl,
|
||||
isLoading: false,
|
||||
lastAccessed: Date.now()
|
||||
}
|
||||
|
||||
this.cache.set(src, updatedEntry)
|
||||
return updatedEntry
|
||||
} catch (error) {
|
||||
console.warn('Failed to cache media:', src, error)
|
||||
|
||||
// Update entry with error
|
||||
const errorEntry: CachedMedia = {
|
||||
src,
|
||||
error: true,
|
||||
isLoading: false,
|
||||
lastAccessed: Date.now()
|
||||
}
|
||||
|
||||
this.cache.set(src, errorEntry)
|
||||
return errorEntry
|
||||
}
|
||||
}
|
||||
|
||||
acquireUrl(src: string): string | undefined {
|
||||
const entry = this.cache.get(src)
|
||||
if (entry?.objectUrl) {
|
||||
const currentCount = this.urlRefCount.get(entry.objectUrl) || 0
|
||||
this.urlRefCount.set(entry.objectUrl, currentCount + 1)
|
||||
return entry.objectUrl
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
releaseUrl(src: string): void {
|
||||
const entry = this.cache.get(src)
|
||||
if (entry?.objectUrl) {
|
||||
const count = (this.urlRefCount.get(entry.objectUrl) || 1) - 1
|
||||
if (count <= 0) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
this.urlRefCount.delete(entry.objectUrl)
|
||||
// Remove from cache as well
|
||||
this.cache.delete(src)
|
||||
} else {
|
||||
this.urlRefCount.set(entry.objectUrl, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
// Revoke all object URLs
|
||||
for (const entry of Array.from(this.cache.values())) {
|
||||
if (entry.objectUrl) {
|
||||
URL.revokeObjectURL(entry.objectUrl)
|
||||
}
|
||||
}
|
||||
this.cache.clear()
|
||||
this.urlRefCount.clear()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
this.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export let mediaCacheInstance: MediaCacheService | null = null
|
||||
|
||||
export function useMediaCache(options?: MediaCacheOptions) {
|
||||
if (!mediaCacheInstance) {
|
||||
mediaCacheInstance = new MediaCacheService(options)
|
||||
}
|
||||
|
||||
const getCachedMedia = (src: string) =>
|
||||
mediaCacheInstance!.getCachedMedia(src)
|
||||
const clearCache = () => mediaCacheInstance!.clearCache()
|
||||
const acquireUrl = (src: string) => mediaCacheInstance!.acquireUrl(src)
|
||||
const releaseUrl = (src: string) => mediaCacheInstance!.releaseUrl(src)
|
||||
|
||||
return {
|
||||
getCachedMedia,
|
||||
clearCache,
|
||||
acquireUrl,
|
||||
releaseUrl,
|
||||
cache: mediaCacheInstance.cache
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (mediaCacheInstance) {
|
||||
mediaCacheInstance.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
35
tests-ui/tests/services/mediaCacheService.test.ts
Normal file
35
tests-ui/tests/services/mediaCacheService.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMediaCache } from '../../../src/services/mediaCacheService'
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
global.URL = {
|
||||
createObjectURL: vi.fn(() => 'blob:mock-url'),
|
||||
revokeObjectURL: vi.fn()
|
||||
} as any
|
||||
|
||||
describe('mediaCacheService', () => {
|
||||
describe('URL reference counting', () => {
|
||||
it('should handle URL acquisition for non-existent cache entry', () => {
|
||||
const { acquireUrl } = useMediaCache()
|
||||
|
||||
const url = acquireUrl('non-existent.jpg')
|
||||
expect(url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle URL release for non-existent cache entry', () => {
|
||||
const { releaseUrl } = useMediaCache()
|
||||
|
||||
// Should not throw error
|
||||
expect(() => releaseUrl('non-existent.jpg')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should provide acquireUrl and releaseUrl methods', () => {
|
||||
const cache = useMediaCache()
|
||||
|
||||
expect(typeof cache.acquireUrl).toBe('function')
|
||||
expect(typeof cache.releaseUrl).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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