mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-26 17:30:07 +00:00
New Workflow Templates Modal (#5142)
This pull request refactors and simplifies the template workflow card components and related UI in the codebase. The main changes focus on removing unused or redundant components, improving visual and interaction consistency, and enhancing error handling for images. Below are the most important changes grouped by theme: **Template Workflow Card Refactor and Cleanup** * Removed the `TemplateWorkflowCard.vue` component and its associated test file `TemplateWorkflowCard.spec.ts`, as well as the `TemplateWorkflowCardSkeleton.vue` and `TemplateWorkflowList.vue` components, indicating a shift away from the previous card-based template workflow UI. [[1]](diffhunk://#diff-49569af0404058e8257f3cc0716b066517ce7397dd58744b02aa0d0c61f2a815L1-L139) [[2]](diffhunk://#diff-9fa6fc1470371f0b520d4deda4129fb313b1bea69888a376556f4bd824f9d751L1-L263) [[3]](diffhunk://#diff-bc35b6f77d1cee6e86b05d0da80b7bd40013c7a6a97a89706d3bc52573e1c574L1-L30) [[4]](diffhunk://#diff-48171f792b22022526fca411d3c3a366d48b675dab77943a20846ae079cbaf3bL1-L68) * Removed the `TemplateSearchBar.vue` component, suggesting a redesign or replacement of the search/filter UI for templates. **UI and Interaction Improvements** * Improved the `CardBottom.vue` component by making its height configurable via a `fullHeight` prop, enhancing layout flexibility. * Updated the `CardContainer.vue` component to add hover effects (background, border, shadow, and padding) and support a new `none` aspect ratio for more flexible card layouts. **Image and Input Enhancements** * Enhanced the `LazyImage.vue` component to display a default placeholder image when an image fails to load, improving error handling and user experience. * Improved the `SearchBox.vue` component by making the input focusable when clicking anywhere on the wrapper, and added a template ref for better accessibility and usability. [[1]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bL2-R5) [[2]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bL16-R17) [[3]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bR33-R39) **Minor UI Tweaks** * Adjusted label styling in `SingleSelect.vue` to remove unnecessary overflow handling, simplifying the visual layout. --------- Co-authored-by: Benjamin Lu <benceruleanlu@proton.me> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: snomiao <snomiao@gmail.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com> Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com> Co-authored-by: Jin Yi <jin12cc@gmail.com>
This commit is contained in:
committed by
GitHub
parent
49f373c46f
commit
d954336973
@@ -1,64 +0,0 @@
|
||||
<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">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:placeholder="$t('templateWorkflows.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="200"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'w-full rounded-2xl'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mt-2">
|
||||
<small
|
||||
v-if="searchQuery && filteredCount !== null"
|
||||
class="text-color-secondary"
|
||||
>
|
||||
{{ $t('g.resultsCount', { count: filteredCount }) }}
|
||||
</small>
|
||||
<Button
|
||||
v-if="searchQuery"
|
||||
text
|
||||
size="small"
|
||||
icon="pi pi-times"
|
||||
:label="$t('g.clearFilters')"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const { filteredCount } = defineProps<{
|
||||
filteredCount?: number | null
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
clearFilters: []
|
||||
}>()
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = ''
|
||||
emit('clearFilters')
|
||||
}
|
||||
</script>
|
||||
@@ -1,273 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/AudioThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'AudioThumbnail',
|
||||
template: '<div class="mock-audio-thumbnail" :data-src="src"></div>',
|
||||
props: ['src']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/CompareSliderThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'CompareSliderThumbnail',
|
||||
template:
|
||||
'<div class="mock-compare-slider" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
|
||||
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/DefaultThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'DefaultThumbnail',
|
||||
template: '<div class="mock-default-thumbnail" :data-src="src"></div>',
|
||||
props: ['src', 'alt', 'isHovered', 'isVideo', 'hoverZoom']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/HoverDissolveThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'HoverDissolveThumbnail',
|
||||
template:
|
||||
'<div class="mock-hover-dissolve" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
|
||||
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useElementHover: () => ref(false)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: (path: string) => `/fileURL${path}`,
|
||||
apiURL: (path: string) => `/apiURL${path}`,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
isLoaded: true,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
groupedTemplates: []
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, fallback: string) => fallback || key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateWorkflows',
|
||||
() => ({
|
||||
useTemplateWorkflows: () => ({
|
||||
getTemplateThumbnailUrl: (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? `/fileURL/templates/${template.name}`
|
||||
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
|
||||
const indexSuffix =
|
||||
sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
},
|
||||
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
},
|
||||
getTemplateDescription: (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string
|
||||
) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
},
|
||||
loadWorkflowTemplate: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('TemplateWorkflowCard', () => {
|
||||
const createTemplate = (overrides = {}): TemplateInfo => ({
|
||||
name: 'test-template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
thumbnailVariant: 'default',
|
||||
description: 'Test description',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountCard = (props = {}) => {
|
||||
return mount(TemplateWorkflowCard, {
|
||||
props: {
|
||||
sourceModule: 'default',
|
||||
categoryTitle: 'Test Category',
|
||||
loading: false,
|
||||
template: createTemplate(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Card: {
|
||||
template:
|
||||
'<div class="card" @click="$emit(\'click\')"><slot name="header" /><slot name="content" /></div>',
|
||||
props: ['dataTestid', 'pt']
|
||||
},
|
||||
ProgressSpinner: {
|
||||
template: '<div class="progress-spinner"></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('emits loadWorkflow event when clicked', async () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ name: 'test-workflow' })
|
||||
})
|
||||
await wrapper.find('.card').trigger('click')
|
||||
expect(wrapper.emitted('loadWorkflow')).toBeTruthy()
|
||||
expect(wrapper.emitted('loadWorkflow')?.[0]).toEqual(['test-workflow'])
|
||||
})
|
||||
|
||||
it('shows loading spinner when loading is true', () => {
|
||||
const wrapper = mountCard({ loading: true })
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders audio thumbnail for audio media type', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ mediaType: 'audio' })
|
||||
})
|
||||
expect(wrapper.find('.mock-audio-thumbnail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders compare slider thumbnail for compareSlider variant', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ thumbnailVariant: 'compareSlider' })
|
||||
})
|
||||
expect(wrapper.find('.mock-compare-slider').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders hover dissolve thumbnail for hoverDissolve variant', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ thumbnailVariant: 'hoverDissolve' })
|
||||
})
|
||||
expect(wrapper.find('.mock-hover-dissolve').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders default thumbnail by default', () => {
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('passes correct props to default thumbnail for video', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ mediaType: 'video' })
|
||||
})
|
||||
const thumbnail = wrapper.find('.mock-default-thumbnail')
|
||||
expect(thumbnail.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses zoomHover scale when variant is zoomHover', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ thumbnailVariant: 'zoomHover' })
|
||||
})
|
||||
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays localized title for default source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'default',
|
||||
template: createTemplate({ localizedTitle: 'My Localized Title' })
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Localized Title')
|
||||
})
|
||||
|
||||
it('displays template name as title for non-default source modules', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'custom',
|
||||
template: createTemplate({ name: 'custom-template' })
|
||||
})
|
||||
expect(wrapper.text()).toContain('custom-template')
|
||||
})
|
||||
|
||||
it('displays localized description for default source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'default',
|
||||
template: createTemplate({
|
||||
localizedDescription: 'My Localized Description'
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Localized Description')
|
||||
})
|
||||
|
||||
it('processes description for non-default source modules', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'custom',
|
||||
template: createTemplate({ description: 'custom_module-description' })
|
||||
})
|
||||
expect(wrapper.text()).toContain('custom module description')
|
||||
})
|
||||
|
||||
it('generates correct thumbnail URLs for default source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'default',
|
||||
template: createTemplate({
|
||||
name: 'my-template',
|
||||
mediaSubtype: 'jpg'
|
||||
})
|
||||
})
|
||||
const vm = wrapper.vm as any
|
||||
expect(vm.baseThumbnailSrc).toBe('/fileURL/templates/my-template-1.jpg')
|
||||
expect(vm.overlayThumbnailSrc).toBe('/fileURL/templates/my-template-2.jpg')
|
||||
})
|
||||
|
||||
it('generates correct thumbnail URLs for custom source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'custom-module',
|
||||
template: createTemplate({
|
||||
name: 'my-template',
|
||||
mediaSubtype: 'png'
|
||||
})
|
||||
})
|
||||
const vm = wrapper.vm as any
|
||||
expect(vm.baseThumbnailSrc).toBe(
|
||||
'/apiURL/workflow_templates/custom-module/my-template.png'
|
||||
)
|
||||
expect(vm.overlayThumbnailSrc).toBe(
|
||||
'/apiURL/workflow_templates/custom-module/my-template.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
<template>
|
||||
<Card
|
||||
ref="cardRef"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
|
||||
:pt="{
|
||||
body: { class: 'p-0 h-full flex flex-col' }
|
||||
}"
|
||||
@click="$emit('loadWorkflow', template.name)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative overflow-hidden rounded-t-lg">
|
||||
<template v-if="template.mediaType === 'audio'">
|
||||
<AudioThumbnail :src="baseThumbnailSrc" />
|
||||
</template>
|
||||
<template v-else-if="template.thumbnailVariant === 'compareSlider'">
|
||||
<CompareSliderThumbnail
|
||||
:base-image-src="baseThumbnailSrc"
|
||||
:overlay-image-src="overlayThumbnailSrc"
|
||||
:alt="title"
|
||||
:is-hovered="isHovered"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="template.thumbnailVariant === 'hoverDissolve'">
|
||||
<HoverDissolveThumbnail
|
||||
:base-image-src="baseThumbnailSrc"
|
||||
:overlay-image-src="overlayThumbnailSrc"
|
||||
:alt="title"
|
||||
:is-hovered="isHovered"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DefaultThumbnail
|
||||
:src="baseThumbnailSrc"
|
||||
:alt="title"
|
||||
:is-hovered="isHovered"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
:hover-zoom="
|
||||
template.thumbnailVariant === 'zoomHover'
|
||||
? UPSCALE_ZOOM_SCALE
|
||||
: DEFAULT_ZOOM_SCALE
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<ProgressSpinner
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-1 w-3/12 h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center px-4 py-3">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import Card from 'primevue/card'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from '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 { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
|
||||
const DEFAULT_ZOOM_SCALE = 5
|
||||
|
||||
const { sourceModule, loading, template } = defineProps<{
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: boolean
|
||||
template: TemplateInfo
|
||||
}>()
|
||||
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = useElementHover(cardRef)
|
||||
|
||||
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
|
||||
useTemplateWorkflows()
|
||||
|
||||
// Determine the effective source module to use (from template or prop)
|
||||
const effectiveSourceModule = computed(
|
||||
() => template.sourceModule || sourceModule
|
||||
)
|
||||
|
||||
const baseThumbnailSrc = computed(() =>
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '1' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const overlayThumbnailSrc = computed(() =>
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '2' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const description = computed(() =>
|
||||
getTemplateDescription(template, effectiveSourceModule.value)
|
||||
)
|
||||
const title = computed(() =>
|
||||
getTemplateTitle(template, effectiveSourceModule.value)
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
<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,68 +0,0 @@
|
||||
<template>
|
||||
<DataTable
|
||||
v-model:selection="selectedTemplate"
|
||||
:value="enrichedTemplates"
|
||||
striped-rows
|
||||
selection-mode="single"
|
||||
>
|
||||
<Column field="title" :header="$t('g.title')">
|
||||
<template #body="slotProps">
|
||||
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="description" :header="$t('g.description')">
|
||||
<template #body="slotProps">
|
||||
<span :title="slotProps.data.description">
|
||||
{{ slotProps.data.description }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="actions" header="" class="w-12">
|
||||
<template #body="slotProps">
|
||||
<Button
|
||||
icon="pi pi-arrow-right"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
:loading="loading === slotProps.data.name"
|
||||
@click="emit('loadWorkflow', slotProps.data.name)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const { sourceModule, loading, templates } = defineProps<{
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: string | null
|
||||
templates: TemplateInfo[]
|
||||
}>()
|
||||
|
||||
const selectedTemplate = ref(null)
|
||||
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
|
||||
|
||||
const enrichedTemplates = computed(() => {
|
||||
return templates.map((template) => {
|
||||
const actualSourceModule = template.sourceModule || sourceModule
|
||||
return {
|
||||
...template,
|
||||
title: getTemplateTitle(template, actualSourceModule),
|
||||
description: getTemplateDescription(template, actualSourceModule)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,185 +0,0 @@
|
||||
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 type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
vi.mock('primevue/dataview', () => ({
|
||||
default: {
|
||||
name: 'DataView',
|
||||
template: `
|
||||
<div class="p-dataview">
|
||||
<div class="dataview-header"><slot name="header"></slot></div>
|
||||
<div class="dataview-content">
|
||||
<slot name="grid" :items="value"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
props: ['value', 'layout', 'lazy', 'pt']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
name: 'SelectButton',
|
||||
template:
|
||||
'<div class="p-selectbutton"><slot name="option" :option="modelValue"></slot></div>',
|
||||
props: ['modelValue', 'options', 'allowEmpty']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
|
||||
default: {
|
||||
template: `
|
||||
<div
|
||||
class="mock-template-card"
|
||||
:data-name="template.name"
|
||||
:data-source-module="sourceModule"
|
||||
:data-category-title="categoryTitle"
|
||||
:data-loading="loading"
|
||||
@click="$emit('loadWorkflow', template.name)"
|
||||
></div>
|
||||
`,
|
||||
props: ['sourceModule', 'categoryTitle', 'loading', 'template'],
|
||||
emits: ['loadWorkflow']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-template-list"></div>',
|
||||
props: ['sourceModule', 'categoryTitle', 'loading', 'templates'],
|
||||
emits: ['loadWorkflow']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-search-bar"></div>',
|
||||
props: ['searchQuery', 'filteredCount'],
|
||||
emits: ['update:searchQuery', '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: '' },
|
||||
filteredTemplates: templates,
|
||||
filteredCount: { value: templates.value?.length || 0 }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowView', () => {
|
||||
const createTemplate = (name: string): TemplateInfo => ({
|
||||
name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
thumbnailVariant: 'default',
|
||||
description: `Description for ${name}`
|
||||
})
|
||||
|
||||
const mountView = (props = {}) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templateWorkflows: {
|
||||
loadingMore: 'Loading more...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mount(TemplateWorkflowView, {
|
||||
props: {
|
||||
title: 'Test Templates',
|
||||
sourceModule: 'default',
|
||||
categoryTitle: 'Test Category',
|
||||
templates: [
|
||||
createTemplate('template-1'),
|
||||
createTemplate('template-2'),
|
||||
createTemplate('template-3')
|
||||
],
|
||||
loading: null,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders template cards for each template', () => {
|
||||
const wrapper = mountView()
|
||||
const cards = wrapper.findAll('.mock-template-card')
|
||||
|
||||
expect(cards.length).toBe(3)
|
||||
expect(cards[0].attributes('data-name')).toBe('template-1')
|
||||
expect(cards[1].attributes('data-name')).toBe('template-2')
|
||||
expect(cards[2].attributes('data-name')).toBe('template-3')
|
||||
})
|
||||
|
||||
it('emits loadWorkflow event when clicked', async () => {
|
||||
const wrapper = mountView()
|
||||
const card = wrapper.find('.mock-template-card')
|
||||
|
||||
await card.trigger('click')
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('loadWorkflow')
|
||||
// Check that the emitted event contains the template name
|
||||
const emitted = wrapper.emitted('loadWorkflow')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted?.[0][0]).toBe('template-1')
|
||||
})
|
||||
|
||||
it('passes correct props to template cards', () => {
|
||||
const wrapper = mountView({
|
||||
sourceModule: 'custom',
|
||||
categoryTitle: 'Custom Category'
|
||||
})
|
||||
|
||||
const card = wrapper.find('.mock-template-card')
|
||||
expect(card.exists()).toBe(true)
|
||||
expect(card.attributes('data-source-module')).toBe('custom')
|
||||
expect(card.attributes('data-category-title')).toBe('Custom Category')
|
||||
})
|
||||
|
||||
it('applies loading state correctly to cards', () => {
|
||||
const wrapper = mountView({
|
||||
loading: 'template-2'
|
||||
})
|
||||
|
||||
const cards = wrapper.findAll('.mock-template-card')
|
||||
|
||||
// Only the second card should have loading=true since loading="template-2"
|
||||
expect(cards[0].attributes('data-loading')).toBe('false')
|
||||
expect(cards[1].attributes('data-loading')).toBe('true')
|
||||
expect(cards[2].attributes('data-loading')).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -1,168 +0,0 @@
|
||||
<template>
|
||||
<DataView
|
||||
:value="displayTemplates"
|
||||
:layout="layout"
|
||||
data-key="name"
|
||||
:lazy="true"
|
||||
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
|
||||
pt:content="p-2 overflow-auto"
|
||||
>
|
||||
<template #header>
|
||||
<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>
|
||||
<TemplateSearchBar
|
||||
v-model:search-query="searchQuery"
|
||||
:filtered-count="filteredCount"
|
||||
@clear-filters="() => reset()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #list="{ items }">
|
||||
<TemplateWorkflowList
|
||||
:source-module="sourceModule"
|
||||
:templates="items"
|
||||
:loading="loading"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #grid="{ items }">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import DataView from 'primevue/dataview'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
|
||||
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
|
||||
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
|
||||
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
|
||||
title: string
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: string | null
|
||||
templates: TemplateInfo[]
|
||||
}>()
|
||||
|
||||
const layout = useLocalStorage<'grid' | 'list'>(
|
||||
'Comfy.TemplateWorkflow.Layout',
|
||||
'grid'
|
||||
)
|
||||
|
||||
const skeletonCount = 6
|
||||
const loadTrigger = ref<HTMLElement | null>(null)
|
||||
|
||||
const templatesRef = computed(() => templates || [])
|
||||
|
||||
const { searchQuery, filteredTemplates, filteredCount } =
|
||||
useTemplateFiltering(templatesRef)
|
||||
|
||||
// When searching, show all results immediately without pagination
|
||||
// When not searching, use lazy pagination
|
||||
const shouldUsePagination = computed(() => !searchQuery.value.trim())
|
||||
|
||||
// Lazy pagination setup using filtered templates
|
||||
const {
|
||||
paginatedItems: paginatedTemplates,
|
||||
isLoading: isLoadingMore,
|
||||
hasMoreItems: hasMoreTemplates,
|
||||
loadNextPage,
|
||||
reset
|
||||
} = useLazyPagination(filteredTemplates, {
|
||||
itemsPerPage: 12
|
||||
})
|
||||
|
||||
// Final templates to display
|
||||
const displayTemplates = computed(() => {
|
||||
return shouldUsePagination.value
|
||||
? paginatedTemplates.value
|
||||
: filteredTemplates.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, searchQuery], () => {
|
||||
reset()
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
const onLoadWorkflow = (name: string) => {
|
||||
emit('loadWorkflow', name)
|
||||
}
|
||||
</script>
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
|
||||
data-testid="template-workflows-content"
|
||||
>
|
||||
<Button
|
||||
v-if="isSmallScreen"
|
||||
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
|
||||
text
|
||||
class="absolute top-1/2 -translate-y-1/2 z-10"
|
||||
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
|
||||
@click="toggleSideNav"
|
||||
/>
|
||||
<Divider
|
||||
class="m-0 [&::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"
|
||||
>
|
||||
<ProgressSpinner
|
||||
v-if="!isTemplatesLoaded || !isReady"
|
||||
class="absolute w-8 h-full inset-0"
|
||||
/>
|
||||
<TemplateWorkflowsSideNav
|
||||
:tabs="allTemplateGroups"
|
||||
:selected-tab="selectedTemplate"
|
||||
@update:selected-tab="handleTabSelection"
|
||||
/>
|
||||
</aside>
|
||||
<div
|
||||
class="flex-1 transition-all duration-300"
|
||||
:class="{
|
||||
'pl-80': 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const {
|
||||
isSmallScreen,
|
||||
isOpen: isSideNavOpen,
|
||||
toggle: toggleSideNav
|
||||
} = useResponsiveCollapse()
|
||||
|
||||
const {
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
loadWorkflowTemplate
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const { isReady } = useAsyncState(loadTemplates, null)
|
||||
|
||||
watch(
|
||||
isReady,
|
||||
() => {
|
||||
if (isReady.value) {
|
||||
selectFirstTemplateCategory()
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
const handleTabSelection = (selection: WorkflowTemplates | null) => {
|
||||
if (selection !== null) {
|
||||
selectTemplateCategory(selection)
|
||||
|
||||
// On small screens, close the sidebar when a category is selected
|
||||
if (isSmallScreen.value) {
|
||||
isSideNavOpen.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadWorkflow = async (id: string) => {
|
||||
if (!isReady.value || !selectedTemplate.value) return false
|
||||
|
||||
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="px-4">
|
||||
<span>{{ $t('templateWorkflows.title') }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,50 +0,0 @@
|
||||
<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"
|
||||
>
|
||||
<template #optiongroup="slotProps">
|
||||
<div class="text-left py-3 px-12">
|
||||
<h2 class="text-lg">
|
||||
{{ slotProps.option.label }}
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
|
||||
import type {
|
||||
TemplateGroup,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
|
||||
defineProps<{
|
||||
tabs: TemplateGroup[]
|
||||
selectedTab: WorkflowTemplates | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedTab', tab: WorkflowTemplates): void
|
||||
}>()
|
||||
|
||||
const handleTabSelection = (tab: WorkflowTemplates) => {
|
||||
emit('update:selectedTab', tab)
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<BaseThumbnail>
|
||||
<div class="w-full h-full flex items-center justify-center p-4">
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center p-4"
|
||||
:style="{
|
||||
backgroundImage: 'url(/assets/images/default-template.png)',
|
||||
backgroundRepeat: 'round'
|
||||
}"
|
||||
>
|
||||
<audio controls class="w-full relative" :src="src" @click.stop />
|
||||
</div>
|
||||
</BaseThumbnail>
|
||||
|
||||
@@ -51,8 +51,9 @@ describe('BaseThumbnail', () => {
|
||||
vm.error = true
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.pi-file').exists()).toBe(true)
|
||||
expect(wrapper.find('.transform-gpu').exists()).toBe(false)
|
||||
expect(
|
||||
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('applies transition classes to content', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="relative w-64 h-64 rounded-t-lg overflow-hidden select-none">
|
||||
<div
|
||||
class="relative w-full aspect-square rounded-t-lg overflow-hidden select-none"
|
||||
>
|
||||
<div
|
||||
v-if="!error"
|
||||
ref="contentRef"
|
||||
@@ -11,7 +13,11 @@
|
||||
<slot />
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<i class="pi pi-file text-4xl" />
|
||||
<img
|
||||
src="/assets/images/default-template.png"
|
||||
draggable="false"
|
||||
class="transform-gpu transition-transform duration-300 ease-out w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user