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>
@@ -80,6 +80,12 @@ test.describe('Templates', () => {
|
|||||||
// Load a template
|
// Load a template
|
||||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||||
await expect(comfyPage.templates.content).toBeVisible()
|
await expect(comfyPage.templates.content).toBeVisible()
|
||||||
|
|
||||||
|
await comfyPage.page
|
||||||
|
.locator(
|
||||||
|
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
|
||||||
|
)
|
||||||
|
.click()
|
||||||
await comfyPage.templates.loadTemplate('default')
|
await comfyPage.templates.loadTemplate('default')
|
||||||
await expect(comfyPage.templates.content).toBeHidden()
|
await expect(comfyPage.templates.content).toBeHidden()
|
||||||
|
|
||||||
@@ -102,48 +108,72 @@ test.describe('Templates', () => {
|
|||||||
expect(await comfyPage.templates.content.isVisible()).toBe(true)
|
expect(await comfyPage.templates.content.isVisible()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Uses title field as fallback when the key is not found in locales', async ({
|
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||||
|
// Set locale to French before opening templates
|
||||||
|
await comfyPage.setSetting('Comfy.Locale', 'fr')
|
||||||
|
|
||||||
|
// Load the templates dialog and wait for the French index file request
|
||||||
|
const requestPromise = comfyPage.page.waitForRequest(
|
||||||
|
'**/templates/index.fr.json'
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||||
|
|
||||||
|
const request = await requestPromise
|
||||||
|
|
||||||
|
// Verify French index was requested
|
||||||
|
expect(request.url()).toContain('templates/index.fr.json')
|
||||||
|
|
||||||
|
await expect(comfyPage.templates.content).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Falls back to English templates when locale file not found', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Capture request for the index.json
|
// Set locale to a language that doesn't have a template file
|
||||||
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
|
await comfyPage.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
|
||||||
// Add a new template that won't have a translation pre-generated
|
|
||||||
const response = [
|
// Wait for the German request (expected to 404)
|
||||||
{
|
const germanRequestPromise = comfyPage.page.waitForRequest(
|
||||||
moduleName: 'default',
|
'**/templates/index.de.json'
|
||||||
title: 'FALLBACK CATEGORY',
|
)
|
||||||
type: 'image',
|
|
||||||
templates: [
|
// Wait for the fallback English request
|
||||||
{
|
const englishRequestPromise = comfyPage.page.waitForRequest(
|
||||||
name: 'unknown_key_has_no_translation_available',
|
'**/templates/index.json'
|
||||||
title: 'FALLBACK TEMPLATE NAME',
|
)
|
||||||
mediaType: 'image',
|
|
||||||
mediaSubtype: 'webp',
|
// Intercept the German file to simulate a 404
|
||||||
description: 'No translations found'
|
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 404,
|
||||||
body: JSON.stringify(response),
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
headers: {
|
body: 'Not Found'
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store'
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Allow the English index to load normally
|
||||||
|
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||||
|
route.continue()
|
||||||
|
)
|
||||||
|
|
||||||
// Load the templates dialog
|
// Load the templates dialog
|
||||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||||
|
await expect(comfyPage.templates.content).toBeVisible()
|
||||||
|
|
||||||
// Expect the title to be used as fallback for template cards
|
// Verify German was requested first, then English as fallback
|
||||||
|
const germanRequest = await germanRequestPromise
|
||||||
|
const englishRequest = await englishRequestPromise
|
||||||
|
|
||||||
|
expect(germanRequest.url()).toContain('templates/index.de.json')
|
||||||
|
expect(englishRequest.url()).toContain('templates/index.json')
|
||||||
|
|
||||||
|
// Verify English titles are shown as fallback
|
||||||
await expect(
|
await expect(
|
||||||
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
|
comfyPage.templates.content.getByRole('heading', {
|
||||||
|
name: 'Image Generation'
|
||||||
|
})
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|
||||||
// Expect the title to be used as fallback for the template categories
|
|
||||||
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('template cards are dynamically sized and responsive', async ({
|
test('template cards are dynamically sized and responsive', async ({
|
||||||
@@ -153,25 +183,43 @@ test.describe('Templates', () => {
|
|||||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||||
await expect(comfyPage.templates.content).toBeVisible()
|
await expect(comfyPage.templates.content).toBeVisible()
|
||||||
|
|
||||||
// Wait for at least one template card to appear
|
const firstCard = comfyPage.page
|
||||||
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
|
.locator('[data-testid^="template-workflow-"]')
|
||||||
timeout: 5000
|
.first()
|
||||||
})
|
await expect(firstCard).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
// Take snapshot of the template grid
|
// Get the template grid
|
||||||
const templateGrid = comfyPage.templates.content.locator('.grid').first()
|
const templateGrid = comfyPage.page.locator(
|
||||||
|
'[data-testid="template-workflows-content"]'
|
||||||
|
)
|
||||||
await expect(templateGrid).toBeVisible()
|
await expect(templateGrid).toBeVisible()
|
||||||
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
|
|
||||||
|
// Check grid layout at desktop size (default)
|
||||||
|
const desktopGridClass = await templateGrid.getAttribute('class')
|
||||||
|
expect(desktopGridClass).toContain('grid')
|
||||||
|
expect(desktopGridClass).toContain(
|
||||||
|
'grid-cols-[repeat(auto-fill,minmax(16rem,1fr))]'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Count visible cards at desktop size
|
||||||
|
const desktopCardCount = await comfyPage.page
|
||||||
|
.locator('[data-testid^="template-workflow-"]')
|
||||||
|
.count()
|
||||||
|
expect(desktopCardCount).toBeGreaterThan(0)
|
||||||
|
|
||||||
// Check cards at mobile viewport size
|
// Check cards at mobile viewport size
|
||||||
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
|
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
|
||||||
await expect(templateGrid).toBeVisible()
|
await expect(templateGrid).toBeVisible()
|
||||||
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
|
// Grid should still be responsive at mobile size
|
||||||
|
const mobileGridClass = await templateGrid.getAttribute('class')
|
||||||
|
expect(mobileGridClass).toContain('grid')
|
||||||
|
|
||||||
// Check cards at tablet size
|
// Check cards at tablet size
|
||||||
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
|
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
|
||||||
await expect(templateGrid).toBeVisible()
|
await expect(templateGrid).toBeVisible()
|
||||||
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
|
// Grid should still be responsive at tablet size
|
||||||
|
const tabletGridClass = await templateGrid.getAttribute('class')
|
||||||
|
expect(tabletGridClass).toContain('grid')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('hover effects work on template cards', async ({ comfyPage }) => {
|
test('hover effects work on template cards', async ({ comfyPage }) => {
|
||||||
@@ -179,10 +227,13 @@ test.describe('Templates', () => {
|
|||||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||||
await expect(comfyPage.templates.content).toBeVisible()
|
await expect(comfyPage.templates.content).toBeVisible()
|
||||||
|
|
||||||
// Get a template card
|
// Get a template card using data-testid
|
||||||
const firstCard = comfyPage.page.locator('.template-card').first()
|
const firstCard = comfyPage.page
|
||||||
|
.locator('[data-testid^="template-workflow-"]')
|
||||||
|
.first()
|
||||||
await expect(firstCard).toBeVisible({ timeout: 5000 })
|
await expect(firstCard).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
|
// Check initial state - card should have transition classes
|
||||||
// Take snapshot before hover
|
// Take snapshot before hover
|
||||||
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
|
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
|
||||||
|
|
||||||
@@ -257,21 +308,42 @@ test.describe('Templates', () => {
|
|||||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||||
await expect(comfyPage.templates.content).toBeVisible()
|
await expect(comfyPage.templates.content).toBeVisible()
|
||||||
|
|
||||||
// Verify cards are visible with varying content lengths
|
// Wait for cards to load
|
||||||
await expect(
|
await expect(
|
||||||
comfyPage.page.getByText('This is a short description.')
|
comfyPage.page.locator(
|
||||||
).toBeVisible({ timeout: 5000 })
|
'[data-testid="template-workflow-short-description"]'
|
||||||
await expect(
|
)
|
||||||
comfyPage.page.getByText('This is a medium length description')
|
|
||||||
).toBeVisible({ timeout: 5000 })
|
|
||||||
await expect(
|
|
||||||
comfyPage.page.getByText('This is a much longer description')
|
|
||||||
).toBeVisible({ timeout: 5000 })
|
).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
// Take snapshot of a grid with specific cards
|
// Verify all three cards with different descriptions are visible
|
||||||
const templateGrid = comfyPage.templates.content
|
const shortDescCard = comfyPage.page.locator(
|
||||||
.locator('.grid:has-text("Short Description")')
|
'[data-testid="template-workflow-short-description"]'
|
||||||
.first()
|
)
|
||||||
|
const mediumDescCard = comfyPage.page.locator(
|
||||||
|
'[data-testid="template-workflow-medium-description"]'
|
||||||
|
)
|
||||||
|
const longDescCard = comfyPage.page.locator(
|
||||||
|
'[data-testid="template-workflow-long-description"]'
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(shortDescCard).toBeVisible()
|
||||||
|
await expect(mediumDescCard).toBeVisible()
|
||||||
|
await expect(longDescCard).toBeVisible()
|
||||||
|
|
||||||
|
// Verify descriptions are visible and have line-clamp class
|
||||||
|
// The description is in a p tag with text-muted class
|
||||||
|
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
|
||||||
|
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
|
||||||
|
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
|
||||||
|
|
||||||
|
await expect(shortDesc).toContainText('short description')
|
||||||
|
await expect(mediumDesc).toContainText('medium length description')
|
||||||
|
await expect(longDesc).toContainText('much longer description')
|
||||||
|
|
||||||
|
// Verify grid layout maintains consistency
|
||||||
|
const templateGrid = comfyPage.page.locator(
|
||||||
|
'[data-testid="template-workflows-content"]'
|
||||||
|
)
|
||||||
await expect(templateGrid).toBeVisible()
|
await expect(templateGrid).toBeVisible()
|
||||||
await expect(templateGrid).toHaveScreenshot(
|
await expect(templateGrid).toHaveScreenshot(
|
||||||
'template-grid-varying-content.png'
|
'template-grid-varying-content.png'
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 116 KiB |
@@ -125,6 +125,7 @@
|
|||||||
"@tiptap/extension-table-row": "^2.10.4",
|
"@tiptap/extension-table-row": "^2.10.4",
|
||||||
"@tiptap/starter-kit": "^2.10.4",
|
"@tiptap/starter-kit": "^2.10.4",
|
||||||
"@vueuse/core": "^11.0.0",
|
"@vueuse/core": "^11.0.0",
|
||||||
|
"@vueuse/integrations": "^13.9.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-serialize": "^0.13.0",
|
"@xterm/addon-serialize": "^0.13.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
|||||||
80
pnpm-lock.yaml
generated
@@ -71,6 +71,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.0.0(vue@3.5.13(typescript@5.9.2))
|
version: 11.0.0(vue@3.5.13(typescript@5.9.2))
|
||||||
|
'@vueuse/integrations':
|
||||||
|
specifier: ^13.9.0
|
||||||
|
version: 13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))
|
||||||
'@xterm/addon-fit':
|
'@xterm/addon-fit':
|
||||||
specifier: ^0.10.0
|
specifier: ^0.10.0
|
||||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||||
@@ -2905,18 +2908,73 @@ packages:
|
|||||||
'@vueuse/core@12.8.2':
|
'@vueuse/core@12.8.2':
|
||||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||||
|
|
||||||
|
'@vueuse/core@13.9.0':
|
||||||
|
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.5.0
|
||||||
|
|
||||||
|
'@vueuse/integrations@13.9.0':
|
||||||
|
resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
async-validator: ^4
|
||||||
|
axios: ^1
|
||||||
|
change-case: ^5
|
||||||
|
drauu: ^0.4
|
||||||
|
focus-trap: ^7
|
||||||
|
fuse.js: ^7
|
||||||
|
idb-keyval: ^6
|
||||||
|
jwt-decode: ^4
|
||||||
|
nprogress: ^0.2
|
||||||
|
qrcode: ^1.5
|
||||||
|
sortablejs: ^1
|
||||||
|
universal-cookie: ^7 || ^8
|
||||||
|
vue: ^3.5.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
async-validator:
|
||||||
|
optional: true
|
||||||
|
axios:
|
||||||
|
optional: true
|
||||||
|
change-case:
|
||||||
|
optional: true
|
||||||
|
drauu:
|
||||||
|
optional: true
|
||||||
|
focus-trap:
|
||||||
|
optional: true
|
||||||
|
fuse.js:
|
||||||
|
optional: true
|
||||||
|
idb-keyval:
|
||||||
|
optional: true
|
||||||
|
jwt-decode:
|
||||||
|
optional: true
|
||||||
|
nprogress:
|
||||||
|
optional: true
|
||||||
|
qrcode:
|
||||||
|
optional: true
|
||||||
|
sortablejs:
|
||||||
|
optional: true
|
||||||
|
universal-cookie:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vueuse/metadata@11.0.0':
|
'@vueuse/metadata@11.0.0':
|
||||||
resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
|
resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
|
||||||
|
|
||||||
'@vueuse/metadata@12.8.2':
|
'@vueuse/metadata@12.8.2':
|
||||||
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
||||||
|
|
||||||
|
'@vueuse/metadata@13.9.0':
|
||||||
|
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
|
||||||
|
|
||||||
'@vueuse/shared@11.0.0':
|
'@vueuse/shared@11.0.0':
|
||||||
resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
|
resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
|
||||||
|
|
||||||
'@vueuse/shared@12.8.2':
|
'@vueuse/shared@12.8.2':
|
||||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||||
|
|
||||||
|
'@vueuse/shared@13.9.0':
|
||||||
|
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.5.0
|
||||||
|
|
||||||
'@webgpu/types@0.1.51':
|
'@webgpu/types@0.1.51':
|
||||||
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
|
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
|
||||||
|
|
||||||
@@ -9675,10 +9733,28 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
'@vueuse/core@13.9.0(vue@3.5.13(typescript@5.9.2))':
|
||||||
|
dependencies:
|
||||||
|
'@types/web-bluetooth': 0.0.21
|
||||||
|
'@vueuse/metadata': 13.9.0
|
||||||
|
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
|
||||||
|
vue: 3.5.13(typescript@5.9.2)
|
||||||
|
|
||||||
|
'@vueuse/integrations@13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))':
|
||||||
|
dependencies:
|
||||||
|
'@vueuse/core': 13.9.0(vue@3.5.13(typescript@5.9.2))
|
||||||
|
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
|
||||||
|
vue: 3.5.13(typescript@5.9.2)
|
||||||
|
optionalDependencies:
|
||||||
|
axios: 1.11.0
|
||||||
|
fuse.js: 7.0.0
|
||||||
|
|
||||||
'@vueuse/metadata@11.0.0': {}
|
'@vueuse/metadata@11.0.0': {}
|
||||||
|
|
||||||
'@vueuse/metadata@12.8.2': {}
|
'@vueuse/metadata@12.8.2': {}
|
||||||
|
|
||||||
|
'@vueuse/metadata@13.9.0': {}
|
||||||
|
|
||||||
'@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))':
|
'@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
|
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
|
||||||
@@ -9692,6 +9768,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
'@vueuse/shared@13.9.0(vue@3.5.13(typescript@5.9.2))':
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.13(typescript@5.9.2)
|
||||||
|
|
||||||
'@webgpu/types@0.1.51': {}
|
'@webgpu/types@0.1.51': {}
|
||||||
|
|
||||||
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||||
|
|||||||
BIN
public/assets/images/default-template.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
10
src/assets/icons/custom/dark-info.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<g clip-path="url(#clip0_1416_62)">
|
||||||
|
<path d="M7.99998 10.6667V8.00004M7.99998 5.33337H8.00665M14.6666 8.00004C14.6666 11.6819 11.6819 14.6667 7.99998 14.6667C4.31808 14.6667 1.33331 11.6819 1.33331 8.00004C1.33331 4.31814 4.31808 1.33337 7.99998 1.33337C11.6819 1.33337 14.6666 4.31814 14.6666 8.00004Z" stroke="#171718" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1416_62">
|
||||||
|
<rect width="16" height="16" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 623 B |
@@ -1,7 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-1 w-full h-full">
|
<div :class="containerClasses">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const { fullHeight = true } = defineProps<{
|
||||||
|
fullHeight?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerClasses = computed(() =>
|
||||||
|
cn('flex-1 w-full', fullHeight && 'h-full')
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -8,15 +8,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const { ratio = 'square' } = defineProps<{
|
const { ratio = 'square', type } = defineProps<{
|
||||||
ratio?: 'square' | 'portrait' | 'tallPortrait'
|
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
|
||||||
|
type?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const containerClasses = computed(() => {
|
const containerClasses = computed(() => {
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
'flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||||
|
|
||||||
|
if (type === 'workflow-template-card') {
|
||||||
|
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
|
||||||
|
}
|
||||||
|
|
||||||
const ratioClasses = {
|
const ratioClasses = {
|
||||||
|
smallSquare: 'aspect-240/311',
|
||||||
square: 'aspect-256/308',
|
square: 'aspect-256/308',
|
||||||
portrait: 'aspect-256/325',
|
portrait: 'aspect-256/325',
|
||||||
tallPortrait: 'aspect-256/353'
|
tallPortrait: 'aspect-256/353'
|
||||||
|
|||||||
@@ -2,26 +2,40 @@
|
|||||||
<div :class="topStyle">
|
<div :class="topStyle">
|
||||||
<slot class="absolute top-0 left-0 w-full h-full"></slot>
|
<slot class="absolute top-0 left-0 w-full h-full"></slot>
|
||||||
|
|
||||||
<div class="absolute top-2 left-2 flex gap-2">
|
<div
|
||||||
|
v-if="slots['top-left']"
|
||||||
|
class="absolute top-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||||
|
>
|
||||||
<slot name="top-left"></slot>
|
<slot name="top-left"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-2 right-2 flex gap-2">
|
<div
|
||||||
|
v-if="slots['top-right']"
|
||||||
|
class="absolute top-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||||
|
>
|
||||||
<slot name="top-right"></slot>
|
<slot name="top-right"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-2 left-2 flex gap-2">
|
<div
|
||||||
|
v-if="slots['bottom-left']"
|
||||||
|
class="absolute bottom-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||||
|
>
|
||||||
<slot name="bottom-left"></slot>
|
<slot name="bottom-left"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-2 right-2 flex gap-2">
|
<div
|
||||||
|
v-if="slots['bottom-right']"
|
||||||
|
class="absolute bottom-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||||
|
>
|
||||||
<slot name="bottom-right"></slot>
|
<slot name="bottom-right"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, useSlots } from 'vue'
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
|
||||||
const { ratio = 'square' } = defineProps<{
|
const { ratio = 'square' } = defineProps<{
|
||||||
ratio?: 'square' | 'landscape'
|
ratio?: 'square' | 'landscape'
|
||||||
|
|||||||
@@ -24,7 +24,13 @@
|
|||||||
v-if="hasError"
|
v-if="hasError"
|
||||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
|
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" />
|
<img
|
||||||
|
src="/assets/images/default-template.png"
|
||||||
|
:alt="alt"
|
||||||
|
draggable="false"
|
||||||
|
:class="imageClass"
|
||||||
|
:style="imageStyle"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
757
src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
<template>
|
||||||
|
<BaseModalLayout
|
||||||
|
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||||
|
class="workflow-template-selector-dialog"
|
||||||
|
>
|
||||||
|
<template #leftPanel>
|
||||||
|
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
|
||||||
|
<template #header-icon>
|
||||||
|
<i class="icon-[comfy--template]" />
|
||||||
|
</template>
|
||||||
|
<template #header-title>
|
||||||
|
<span class="text-neutral text-base">{{
|
||||||
|
$t('sideToolbar.templates', 'Templates')
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</LeftSidePanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #header>
|
||||||
|
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #header-right-area>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<IconTextButton
|
||||||
|
v-if="filteredCount !== totalCount"
|
||||||
|
type="secondary"
|
||||||
|
:label="$t('templateWorkflows.resetFilters', 'Clear Filters')"
|
||||||
|
@click="resetFilters"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i-lucide:filter-x />
|
||||||
|
</template>
|
||||||
|
</IconTextButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #contentFilter>
|
||||||
|
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap">
|
||||||
|
<!-- Model Filter -->
|
||||||
|
<MultiSelect
|
||||||
|
v-model="selectedModelObjects"
|
||||||
|
v-model:search-query="modelSearchText"
|
||||||
|
class="w-[250px]"
|
||||||
|
:label="modelFilterLabel"
|
||||||
|
:options="modelOptions"
|
||||||
|
:show-search-box="true"
|
||||||
|
:show-selected-count="true"
|
||||||
|
:show-clear-button="true"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i-lucide:cpu />
|
||||||
|
</template>
|
||||||
|
</MultiSelect>
|
||||||
|
|
||||||
|
<!-- Use Case Filter -->
|
||||||
|
<MultiSelect
|
||||||
|
v-model="selectedUseCaseObjects"
|
||||||
|
:label="useCaseFilterLabel"
|
||||||
|
:options="useCaseOptions"
|
||||||
|
:show-search-box="true"
|
||||||
|
:show-selected-count="true"
|
||||||
|
:show-clear-button="true"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i-lucide:target />
|
||||||
|
</template>
|
||||||
|
</MultiSelect>
|
||||||
|
|
||||||
|
<!-- License Filter -->
|
||||||
|
<MultiSelect
|
||||||
|
v-model="selectedLicenseObjects"
|
||||||
|
:label="licenseFilterLabel"
|
||||||
|
:options="licenseOptions"
|
||||||
|
:show-search-box="true"
|
||||||
|
:show-selected-count="true"
|
||||||
|
:show-clear-button="true"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i-lucide:file-text />
|
||||||
|
</template>
|
||||||
|
</MultiSelect>
|
||||||
|
|
||||||
|
<!-- Sort Options -->
|
||||||
|
<div class="absolute right-5">
|
||||||
|
<SingleSelect
|
||||||
|
v-model="sortBy"
|
||||||
|
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||||
|
:options="sortOptions"
|
||||||
|
class="min-w-[270px]"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i-lucide:arrow-up-down />
|
||||||
|
</template>
|
||||||
|
</SingleSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!isLoading"
|
||||||
|
class="px-6 pt-4 pb-2 text-2xl font-semibold text-neutral"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ pageTitle }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<!-- No Results State (only show when loaded and no results) -->
|
||||||
|
<div
|
||||||
|
v-if="!isLoading && filteredTemplates.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center h-64 text-neutral-500"
|
||||||
|
>
|
||||||
|
<i-lucide:search class="w-12 h-12 mb-4 opacity-50" />
|
||||||
|
<p class="text-lg mb-2">
|
||||||
|
{{ $t('templateWorkflows.noResults', 'No templates found') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
'templateWorkflows.noResultsHint',
|
||||||
|
'Try adjusting your search or filters'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<!-- Title -->
|
||||||
|
<span
|
||||||
|
v-if="isLoading"
|
||||||
|
class="inline-block h-8 w-48 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<!-- Template Cards Grid -->
|
||||||
|
<div
|
||||||
|
:key="templateListKey"
|
||||||
|
:style="gridStyle"
|
||||||
|
data-testid="template-workflows-content"
|
||||||
|
>
|
||||||
|
<!-- Loading Skeletons (show while loading initial data) -->
|
||||||
|
<CardContainer
|
||||||
|
v-for="n in isLoading ? 12 : 0"
|
||||||
|
:key="`initial-skeleton-${n}`"
|
||||||
|
ratio="smallSquare"
|
||||||
|
type="workflow-template-card"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<CardTop ratio="landscape">
|
||||||
|
<template #default>
|
||||||
|
<div
|
||||||
|
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</CardTop>
|
||||||
|
</template>
|
||||||
|
<template #bottom>
|
||||||
|
<CardBottom>
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<div
|
||||||
|
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</CardBottom>
|
||||||
|
</template>
|
||||||
|
</CardContainer>
|
||||||
|
|
||||||
|
<!-- Actual Template Cards -->
|
||||||
|
<CardContainer
|
||||||
|
v-for="template in isLoading ? [] : displayTemplates"
|
||||||
|
:key="template.name"
|
||||||
|
ref="cardRefs"
|
||||||
|
v-memo="[template.name, hoveredTemplate === template.name]"
|
||||||
|
ratio="smallSquare"
|
||||||
|
type="workflow-template-card"
|
||||||
|
:data-testid="`template-workflow-${template.name}`"
|
||||||
|
@mouseenter="hoveredTemplate = template.name"
|
||||||
|
@mouseleave="hoveredTemplate = null"
|
||||||
|
@click="onLoadWorkflow(template)"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<CardTop ratio="square">
|
||||||
|
<template #default>
|
||||||
|
<!-- Template Thumbnail -->
|
||||||
|
<div
|
||||||
|
class="w-full h-full relative rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<template v-if="template.mediaType === 'audio'">
|
||||||
|
<AudioThumbnail :src="getBaseThumbnailSrc(template)" />
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="template.thumbnailVariant === 'compareSlider'"
|
||||||
|
>
|
||||||
|
<CompareSliderThumbnail
|
||||||
|
:base-image-src="getBaseThumbnailSrc(template)"
|
||||||
|
:overlay-image-src="getOverlayThumbnailSrc(template)"
|
||||||
|
:alt="
|
||||||
|
getTemplateTitle(
|
||||||
|
template,
|
||||||
|
getEffectiveSourceModule(template)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:is-hovered="hoveredTemplate === template.name"
|
||||||
|
:is-video="
|
||||||
|
template.mediaType === 'video' ||
|
||||||
|
template.mediaSubtype === 'webp'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="template.thumbnailVariant === 'hoverDissolve'"
|
||||||
|
>
|
||||||
|
<HoverDissolveThumbnail
|
||||||
|
:base-image-src="getBaseThumbnailSrc(template)"
|
||||||
|
:overlay-image-src="getOverlayThumbnailSrc(template)"
|
||||||
|
:alt="
|
||||||
|
getTemplateTitle(
|
||||||
|
template,
|
||||||
|
getEffectiveSourceModule(template)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:is-hovered="hoveredTemplate === template.name"
|
||||||
|
:is-video="
|
||||||
|
template.mediaType === 'video' ||
|
||||||
|
template.mediaSubtype === 'webp'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<DefaultThumbnail
|
||||||
|
:src="getBaseThumbnailSrc(template)"
|
||||||
|
:alt="
|
||||||
|
getTemplateTitle(
|
||||||
|
template,
|
||||||
|
getEffectiveSourceModule(template)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:is-hovered="hoveredTemplate === template.name"
|
||||||
|
:is-video="
|
||||||
|
template.mediaType === 'video' ||
|
||||||
|
template.mediaSubtype === 'webp'
|
||||||
|
"
|
||||||
|
:hover-zoom="
|
||||||
|
template.thumbnailVariant === 'zoomHover' ? 16 : 5
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<ProgressSpinner
|
||||||
|
v-if="loadingTemplate === template.name"
|
||||||
|
class="absolute inset-0 z-10 w-12 h-12 m-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #bottom-right>
|
||||||
|
<template v-if="template.tags && template.tags.length > 0">
|
||||||
|
<SquareChip
|
||||||
|
v-for="tag in template.tags"
|
||||||
|
:key="tag"
|
||||||
|
:label="tag"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</CardTop>
|
||||||
|
</template>
|
||||||
|
<template #bottom>
|
||||||
|
<CardBottom>
|
||||||
|
<div class="flex flex-col gap-2 pt-3">
|
||||||
|
<h3
|
||||||
|
class="line-clamp-1 text-sm m-0"
|
||||||
|
:title="
|
||||||
|
getTemplateTitle(
|
||||||
|
template,
|
||||||
|
getEffectiveSourceModule(template)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
getTemplateTitle(
|
||||||
|
template,
|
||||||
|
getEffectiveSourceModule(template)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p
|
||||||
|
class="line-clamp-2 text-sm text-muted m-0"
|
||||||
|
:title="getTemplateDescription(template)"
|
||||||
|
>
|
||||||
|
{{ getTemplateDescription(template) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="template.tutorialUrl"
|
||||||
|
class="flex flex-col-reverse justify-center"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
v-if="hoveredTemplate === template.name"
|
||||||
|
v-tooltip.bottom="$t('g.seeTutorial')"
|
||||||
|
v-bind="$attrs"
|
||||||
|
type="primary"
|
||||||
|
size="sm"
|
||||||
|
@click.stop="openTutorial(template)"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--info] size-4" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBottom>
|
||||||
|
</template>
|
||||||
|
</CardContainer>
|
||||||
|
|
||||||
|
<!-- Loading More Skeletons -->
|
||||||
|
<CardContainer
|
||||||
|
v-for="n in isLoadingMore ? 6 : 0"
|
||||||
|
:key="`skeleton-${n}`"
|
||||||
|
ratio="smallSquare"
|
||||||
|
type="workflow-template-card"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<CardTop ratio="square">
|
||||||
|
<template #default>
|
||||||
|
<div
|
||||||
|
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</CardTop>
|
||||||
|
</template>
|
||||||
|
<template #bottom>
|
||||||
|
<CardBottom>
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<div
|
||||||
|
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</CardBottom>
|
||||||
|
</template>
|
||||||
|
</CardContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load More Trigger -->
|
||||||
|
<div
|
||||||
|
v-if="!isLoading && hasMoreTemplates"
|
||||||
|
ref="loadTrigger"
|
||||||
|
class="w-full h-4 flex justify-center items-center mt-4"
|
||||||
|
>
|
||||||
|
<div v-if="isLoadingMore" class="text-sm text-muted">
|
||||||
|
{{ $t('templateWorkflows.loadingMore', 'Loading more...') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Summary -->
|
||||||
|
<div
|
||||||
|
v-if="!isLoading"
|
||||||
|
class="mt-6 px-6 text-sm text-neutral-600 dark-theme:text-neutral-400"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
$t('templateWorkflows.resultsCount', {
|
||||||
|
count: filteredCount,
|
||||||
|
total: totalCount
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseModalLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAsyncState } from '@vueuse/core'
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
|
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import IconButton from '@/components/button/IconButton.vue'
|
||||||
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
|
import CardBottom from '@/components/card/CardBottom.vue'
|
||||||
|
import CardContainer from '@/components/card/CardContainer.vue'
|
||||||
|
import CardTop from '@/components/card/CardTop.vue'
|
||||||
|
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||||
|
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||||
|
import SearchBox from '@/components/input/SearchBox.vue'
|
||||||
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
|
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||||
|
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||||
|
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||||
|
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||||
|
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||||
|
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||||
|
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||||
|
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||||
|
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||||
|
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||||
|
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||||
|
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||||
|
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||||
|
import { OnCloseKey } from '@/types/widgetTypes'
|
||||||
|
import { createGridStyle } from '@/utils/gridUtil'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { onClose } = defineProps<{
|
||||||
|
onClose: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
provide(OnCloseKey, onClose)
|
||||||
|
|
||||||
|
// Workflow templates store and composable
|
||||||
|
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||||
|
const {
|
||||||
|
loadTemplates,
|
||||||
|
loadWorkflowTemplate,
|
||||||
|
getTemplateThumbnailUrl,
|
||||||
|
getTemplateTitle,
|
||||||
|
getTemplateDescription
|
||||||
|
} = useTemplateWorkflows()
|
||||||
|
|
||||||
|
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||||
|
template.sourceModule || 'default'
|
||||||
|
|
||||||
|
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||||
|
const sm = getEffectiveSourceModule(template)
|
||||||
|
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
|
||||||
|
const sm = getEffectiveSourceModule(template)
|
||||||
|
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open tutorial in new tab
|
||||||
|
const openTutorial = (template: TemplateInfo) => {
|
||||||
|
if (template.tutorialUrl) {
|
||||||
|
window.open(template.tutorialUrl, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get navigation items from the store, with skeleton items while loading
|
||||||
|
const navItems = computed<(NavItemData | NavGroupData)[]>(() => {
|
||||||
|
// Show skeleton navigation items while loading
|
||||||
|
if (isLoading.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'skeleton-all',
|
||||||
|
label: 'All Templates',
|
||||||
|
icon: 'icon-[lucide--layout-grid]'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'skeleton-basics',
|
||||||
|
label: 'Basics',
|
||||||
|
icon: 'icon-[lucide--graduation-cap]'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Generation Type',
|
||||||
|
items: [
|
||||||
|
{ id: 'skeleton-1', label: '...', icon: 'icon-[lucide--loader-2]' },
|
||||||
|
{ id: 'skeleton-2', label: '...', icon: 'icon-[lucide--loader-2]' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Closed Source Models',
|
||||||
|
items: [
|
||||||
|
{ id: 'skeleton-3', label: '...', icon: 'icon-[lucide--loader-2]' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return workflowTemplatesStore.navGroupedTemplates
|
||||||
|
})
|
||||||
|
|
||||||
|
const gridStyle = computed(() => createGridStyle())
|
||||||
|
|
||||||
|
// Get enhanced templates for better filtering
|
||||||
|
const allTemplates = computed(() => {
|
||||||
|
return workflowTemplatesStore.enhancedTemplates
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter templates based on selected navigation item
|
||||||
|
const navigationFilteredTemplates = computed(() => {
|
||||||
|
if (!selectedNavItem.value) {
|
||||||
|
return allTemplates.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflowTemplatesStore.filterTemplatesByCategory(selectedNavItem.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Template filtering
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
selectedModels,
|
||||||
|
selectedUseCases,
|
||||||
|
selectedLicenses,
|
||||||
|
sortBy,
|
||||||
|
filteredTemplates,
|
||||||
|
availableModels,
|
||||||
|
availableUseCases,
|
||||||
|
availableLicenses,
|
||||||
|
filteredCount,
|
||||||
|
totalCount,
|
||||||
|
resetFilters
|
||||||
|
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||||
|
|
||||||
|
// Convert between string array and object array for MultiSelect component
|
||||||
|
const selectedModelObjects = computed({
|
||||||
|
get() {
|
||||||
|
return selectedModels.value.map((model) => ({ name: model, value: model }))
|
||||||
|
},
|
||||||
|
set(value: { name: string; value: string }[]) {
|
||||||
|
selectedModels.value = value.map((item) => item.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedUseCaseObjects = computed({
|
||||||
|
get() {
|
||||||
|
return selectedUseCases.value.map((useCase) => ({
|
||||||
|
name: useCase,
|
||||||
|
value: useCase
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
set(value: { name: string; value: string }[]) {
|
||||||
|
selectedUseCases.value = value.map((item) => item.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedLicenseObjects = computed({
|
||||||
|
get() {
|
||||||
|
return selectedLicenses.value.map((license) => ({
|
||||||
|
name: license,
|
||||||
|
value: license
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
set(value: { name: string; value: string }[]) {
|
||||||
|
selectedLicenses.value = value.map((item) => item.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
const loadingTemplate = ref<string | null>(null)
|
||||||
|
const hoveredTemplate = ref<string | null>(null)
|
||||||
|
const cardRefs = ref<HTMLElement[]>([])
|
||||||
|
|
||||||
|
// Force re-render key for templates when sorting changes
|
||||||
|
const templateListKey = ref(0)
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
const selectedNavItem = ref<string | null>('all')
|
||||||
|
|
||||||
|
// Search text for model filter
|
||||||
|
const modelSearchText = ref<string>('')
|
||||||
|
|
||||||
|
// Filter options
|
||||||
|
const modelOptions = computed(() =>
|
||||||
|
availableModels.value.map((model) => ({
|
||||||
|
name: model,
|
||||||
|
value: model
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const useCaseOptions = computed(() =>
|
||||||
|
availableUseCases.value.map((useCase) => ({
|
||||||
|
name: useCase,
|
||||||
|
value: useCase
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const licenseOptions = computed(() =>
|
||||||
|
availableLicenses.value.map((license) => ({
|
||||||
|
name: license,
|
||||||
|
value: license
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter labels
|
||||||
|
const modelFilterLabel = computed(() => {
|
||||||
|
if (selectedModelObjects.value.length === 0) {
|
||||||
|
return t('templateWorkflows.modelFilter', 'Model Filter')
|
||||||
|
} else if (selectedModelObjects.value.length === 1) {
|
||||||
|
return selectedModelObjects.value[0].name
|
||||||
|
} else {
|
||||||
|
return t('templateWorkflows.modelsSelected', {
|
||||||
|
count: selectedModelObjects.value.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const useCaseFilterLabel = computed(() => {
|
||||||
|
if (selectedUseCaseObjects.value.length === 0) {
|
||||||
|
return t('templateWorkflows.useCaseFilter', 'Use Case')
|
||||||
|
} else if (selectedUseCaseObjects.value.length === 1) {
|
||||||
|
return selectedUseCaseObjects.value[0].name
|
||||||
|
} else {
|
||||||
|
return t('templateWorkflows.useCasesSelected', {
|
||||||
|
count: selectedUseCaseObjects.value.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const licenseFilterLabel = computed(() => {
|
||||||
|
if (selectedLicenseObjects.value.length === 0) {
|
||||||
|
return t('templateWorkflows.licenseFilter', 'License')
|
||||||
|
} else if (selectedLicenseObjects.value.length === 1) {
|
||||||
|
return selectedLicenseObjects.value[0].name
|
||||||
|
} else {
|
||||||
|
return t('templateWorkflows.licensesSelected', {
|
||||||
|
count: selectedLicenseObjects.value.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort options
|
||||||
|
const sortOptions = computed(() => [
|
||||||
|
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||||
|
{
|
||||||
|
name: t('templateWorkflows.sort.default', 'Default'),
|
||||||
|
value: 'default'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t(
|
||||||
|
'templateWorkflows.sort.vramLowToHigh',
|
||||||
|
'VRAM Utilization (Low to High)'
|
||||||
|
),
|
||||||
|
value: 'vram-low-to-high'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t(
|
||||||
|
'templateWorkflows.sort.modelSizeLowToHigh',
|
||||||
|
'Model Size (Low to High)'
|
||||||
|
),
|
||||||
|
value: 'model-size-low-to-high'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('templateWorkflows.sort.alphabetical', 'Alphabetical (A-Z)'),
|
||||||
|
value: 'alphabetical'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// Lazy pagination setup
|
||||||
|
const loadTrigger = ref<HTMLElement | null>(null)
|
||||||
|
const shouldUsePagination = computed(() => !searchQuery.value.trim())
|
||||||
|
|
||||||
|
const {
|
||||||
|
paginatedItems: paginatedTemplates,
|
||||||
|
isLoading: isLoadingMore,
|
||||||
|
hasMoreItems: hasMoreTemplates,
|
||||||
|
loadNextPage,
|
||||||
|
reset: resetPagination
|
||||||
|
} = useLazyPagination(filteredTemplates, { itemsPerPage: 24 }) // Load 24 items per page
|
||||||
|
|
||||||
|
// Display templates (all when searching, paginated when not)
|
||||||
|
const displayTemplates = computed(() => {
|
||||||
|
return shouldUsePagination.value
|
||||||
|
? paginatedTemplates.value
|
||||||
|
: filteredTemplates.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set up intersection observer for lazy loading
|
||||||
|
useIntersectionObserver(loadTrigger, () => {
|
||||||
|
if (
|
||||||
|
shouldUsePagination.value &&
|
||||||
|
hasMoreTemplates.value &&
|
||||||
|
!isLoadingMore.value
|
||||||
|
) {
|
||||||
|
void loadNextPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset pagination when filters change
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
searchQuery,
|
||||||
|
selectedNavItem,
|
||||||
|
sortBy,
|
||||||
|
selectedModels,
|
||||||
|
selectedUseCases,
|
||||||
|
selectedLicenses
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
resetPagination()
|
||||||
|
// Clear loading state and force re-render of template list
|
||||||
|
loadingTemplate.value = null
|
||||||
|
templateListKey.value++
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const onLoadWorkflow = async (template: any) => {
|
||||||
|
loadingTemplate.value = template.name
|
||||||
|
try {
|
||||||
|
await loadWorkflowTemplate(
|
||||||
|
template.name,
|
||||||
|
getEffectiveSourceModule(template)
|
||||||
|
)
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
loadingTemplate.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
const navItem = navItems.value.find((item) =>
|
||||||
|
'id' in item
|
||||||
|
? item.id === selectedNavItem.value
|
||||||
|
: item.items?.some((sub) => sub.id === selectedNavItem.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!navItem) {
|
||||||
|
return t('templateWorkflows.allTemplates', 'All Templates')
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'id' in navItem
|
||||||
|
? navItem.label
|
||||||
|
: navItem.items?.find((i) => i.id === selectedNavItem.value)?.label ||
|
||||||
|
t('templateWorkflows.allTemplates', 'All Templates')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize templates loading with useAsyncState
|
||||||
|
const { isLoading } = useAsyncState(
|
||||||
|
async () => {
|
||||||
|
// Run both operations in parallel for better performance
|
||||||
|
await Promise.all([
|
||||||
|
loadTemplates(),
|
||||||
|
workflowTemplatesStore.loadWorkflowTemplates()
|
||||||
|
])
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
false, // initial state
|
||||||
|
{
|
||||||
|
immediate: true // Start loading immediately
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cardRefs.value = [] // Release DOM refs
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Ensure the workflow template selector dialog fits within provided dialog */
|
||||||
|
.workflow-template-selector-dialog.base-widget-layout {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 1400px;
|
||||||
|
height: 100% !important;
|
||||||
|
aspect-ratio: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
.workflow-template-selector-dialog.base-widget-layout {
|
||||||
|
max-width: 1600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
-->
|
-->
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-model="selectedItems"
|
v-model="selectedItems"
|
||||||
v-bind="$attrs"
|
v-bind="{ ...$attrs, options: filteredOptions }"
|
||||||
option-label="name"
|
option-label="name"
|
||||||
unstyled
|
unstyled
|
||||||
:max-selected-labels="0"
|
:max-selected-labels="0"
|
||||||
@@ -105,10 +105,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { type UseFuseOptions, useFuse } from '@vueuse/integrations/useFuse'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
|
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
|
||||||
import MultiSelect from 'primevue/multiselect'
|
import MultiSelect from 'primevue/multiselect'
|
||||||
import { computed } from 'vue'
|
import { computed, useAttrs } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SearchBox from '@/components/input/SearchBox.vue'
|
import SearchBox from '@/components/input/SearchBox.vue'
|
||||||
@@ -158,7 +159,7 @@ const {
|
|||||||
const selectedItems = defineModel<Option[]>({
|
const selectedItems = defineModel<Option[]>({
|
||||||
required: true
|
required: true
|
||||||
})
|
})
|
||||||
const searchQuery = defineModel<string>('searchQuery')
|
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const selectedCount = computed(() => selectedItems.value.length)
|
const selectedCount = computed(() => selectedItems.value.length)
|
||||||
@@ -167,6 +168,40 @@ const popoverStyle = usePopoverSizing({
|
|||||||
minWidth: popoverMinWidth,
|
minWidth: popoverMinWidth,
|
||||||
maxWidth: popoverMaxWidth
|
maxWidth: popoverMaxWidth
|
||||||
})
|
})
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const originalOptions = computed(() => (attrs.options as Option[]) || [])
|
||||||
|
|
||||||
|
// Use VueUse's useFuse for better reactivity and performance
|
||||||
|
const fuseOptions: UseFuseOptions<Option> = {
|
||||||
|
fuseOptions: {
|
||||||
|
keys: ['name', 'value'],
|
||||||
|
threshold: 0.3,
|
||||||
|
includeScore: false
|
||||||
|
},
|
||||||
|
matchAllWhenSearchEmpty: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
|
||||||
|
|
||||||
|
// Filter options based on search, but always include selected items
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
if (!searchQuery.value || searchQuery.value.trim() === '') {
|
||||||
|
return originalOptions.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// results.value already contains the search results from useFuse
|
||||||
|
const searchResults = results.value.map(
|
||||||
|
(result: { item: Option }) => result.item
|
||||||
|
)
|
||||||
|
|
||||||
|
// Include selected items that aren't in search results
|
||||||
|
const selectedButNotInResults = selectedItems.value.filter(
|
||||||
|
(item) =>
|
||||||
|
!searchResults.some((result: Option) => result.value === item.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [...selectedButNotInResults, ...searchResults]
|
||||||
|
})
|
||||||
|
|
||||||
const pt = computed(() => ({
|
const pt = computed(() => ({
|
||||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="wrapperStyle">
|
<div :class="wrapperStyle" @click="focusInput">
|
||||||
<i-lucide:search :class="iconColorStyle" />
|
<i-lucide:search :class="iconColorStyle" />
|
||||||
<InputText
|
<InputText
|
||||||
|
ref="input"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
:placeholder="placeHolder || 'Search...'"
|
:aria-label="
|
||||||
|
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
|
||||||
|
"
|
||||||
|
:placeholder="
|
||||||
|
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
|
||||||
|
"
|
||||||
type="text"
|
type="text"
|
||||||
unstyled
|
unstyled
|
||||||
:class="inputStyle"
|
:class="inputStyle"
|
||||||
@@ -13,8 +19,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import InputText from 'primevue/inputtext'
|
import InputText from 'primevue/inputtext'
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { t } from '@/i18n'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -29,6 +36,13 @@ const {
|
|||||||
// defineModel without arguments uses 'modelValue' as the prop name
|
// defineModel without arguments uses 'modelValue' as the prop name
|
||||||
const searchQuery = defineModel<string>()
|
const searchQuery = defineModel<string>()
|
||||||
|
|
||||||
|
const input = ref<{ $el: HTMLElement } | null>()
|
||||||
|
const focusInput = () => {
|
||||||
|
if (input.value && input.value.$el) {
|
||||||
|
input.value.$el.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const wrapperStyle = computed(() => {
|
const wrapperStyle = computed(() => {
|
||||||
const baseClasses = [
|
const baseClasses = [
|
||||||
'relative flex w-full items-center gap-2',
|
'relative flex w-full items-center gap-2',
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ const pt = computed(() => ({
|
|||||||
label: {
|
label: {
|
||||||
class:
|
class:
|
||||||
// Align with MultiSelect labelContainer spacing
|
// Align with MultiSelect labelContainer spacing
|
||||||
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 outline-hidden'
|
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||||
},
|
},
|
||||||
dropdown: {
|
dropdown: {
|
||||||
class:
|
class:
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<BaseThumbnail>
|
<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 />
|
<audio controls class="w-full relative" :src="src" @click.stop />
|
||||||
</div>
|
</div>
|
||||||
</BaseThumbnail>
|
</BaseThumbnail>
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ describe('BaseThumbnail', () => {
|
|||||||
vm.error = true
|
vm.error = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(wrapper.find('.pi-file').exists()).toBe(true)
|
expect(
|
||||||
expect(wrapper.find('.transform-gpu').exists()).toBe(false)
|
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
|
||||||
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies transition classes to content', () => {
|
it('applies transition classes to content', () => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<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
|
<div
|
||||||
v-if="!error"
|
v-if="!error"
|
||||||
ref="contentRef"
|
ref="contentRef"
|
||||||
@@ -11,7 +13,11 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full h-full flex items-center justify-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<i :class="icon" class="text-xs text-neutral" />
|
<i :class="icon" class="text-sm text-neutral" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,13 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<h3
|
<div
|
||||||
class="m-0 px-3 py-0 pt-5 text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center justify-between m-0 px-3 py-0 pt-5',
|
||||||
|
collapsible && 'cursor-pointer select-none'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="collapsible && toggleCollapse()"
|
||||||
>
|
>
|
||||||
{{ title }}
|
<h3
|
||||||
</h3>
|
class="text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<i
|
||||||
|
v-if="collapsible"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'pi transition-transform duration-200 text-xs text-neutral-400 dark-theme:text-neutral-400',
|
||||||
|
isCollapsed ? 'pi-chevron-right' : 'pi-chevron-down'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { title } = defineProps<{
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
modelValue = false,
|
||||||
|
collapsible = false
|
||||||
|
} = defineProps<{
|
||||||
title: string
|
title: string
|
||||||
|
modelValue?: boolean
|
||||||
|
collapsible?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isCollapsed = computed({
|
||||||
|
get: () => modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleCollapse = () => {
|
||||||
|
isCollapsed.value = !isCollapsed.value
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,19 +7,27 @@
|
|||||||
<slot name="header-title"></slot>
|
<slot name="header-title"></slot>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
<nav class="flex-1 px-3 py-4 flex flex-col gap-1">
|
<nav
|
||||||
|
class="flex-1 px-3 py-4 flex flex-col gap-1 overflow-y-auto scrollbar-hide"
|
||||||
|
>
|
||||||
<template v-for="(item, index) in navItems" :key="index">
|
<template v-for="(item, index) in navItems" :key="index">
|
||||||
<div v-if="'items' in item" class="flex flex-col gap-2">
|
<div v-if="'items' in item" class="flex flex-col gap-2">
|
||||||
<NavTitle :title="item.title" />
|
<NavTitle
|
||||||
<NavItem
|
v-model="collapsedGroups[item.title]"
|
||||||
v-for="subItem in item.items"
|
:title="item.title"
|
||||||
:key="subItem.id"
|
:collapsible="item.collapsible"
|
||||||
:icon="subItem.icon"
|
/>
|
||||||
:active="activeItem === subItem.id"
|
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
|
||||||
@click="activeItem = subItem.id"
|
<NavItem
|
||||||
>
|
v-for="subItem in item.items"
|
||||||
{{ subItem.label }}
|
:key="subItem.id"
|
||||||
</NavItem>
|
:icon="subItem.icon"
|
||||||
|
:active="activeItem === subItem.id"
|
||||||
|
@click="activeItem = subItem.id"
|
||||||
|
>
|
||||||
|
{{ subItem.label }}
|
||||||
|
</NavItem>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col gap-2">
|
<div v-else class="flex flex-col gap-2">
|
||||||
<NavItem
|
<NavItem
|
||||||
@@ -36,7 +44,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import NavItem from '@/components/widget/nav/NavItem.vue'
|
import NavItem from '@/components/widget/nav/NavItem.vue'
|
||||||
import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||||
@@ -53,6 +61,9 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [value: string | null]
|
'update:modelValue': [value: string | null]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Track collapsed state for each group
|
||||||
|
const collapsedGroups = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
const getFirstItemId = () => {
|
const getFirstItemId = () => {
|
||||||
if (!navItems || navItems.length === 0) {
|
if (!navItems || navItems.length === 0) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import {
|
|||||||
} from '@/workbench/extensions/manager/composables/useManagerState'
|
} from '@/workbench/extensions/manager/composables/useManagerState'
|
||||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||||
|
|
||||||
|
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
|
||||||
|
|
||||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||||
|
|
||||||
export function useCoreCommands(): ComfyCommand[] {
|
export function useCoreCommands(): ComfyCommand[] {
|
||||||
@@ -264,7 +266,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
icon: 'pi pi-folder-open',
|
icon: 'pi pi-folder-open',
|
||||||
label: 'Browse Templates',
|
label: 'Browse Templates',
|
||||||
function: () => {
|
function: () => {
|
||||||
dialogService.showTemplateWorkflowsDialog()
|
useWorkflowTemplateSelectorDialog().show()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,57 +1,213 @@
|
|||||||
|
import { refDebounced } from '@vueuse/core'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
import { type Ref, computed, ref } from 'vue'
|
import { type Ref, computed, ref } from 'vue'
|
||||||
|
|
||||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||||
|
|
||||||
// @ts-expect-error unused (To be used later?)
|
|
||||||
interface TemplateFilterOptions {
|
|
||||||
searchQuery?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTemplateFiltering(
|
export function useTemplateFiltering(
|
||||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||||
) {
|
) {
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const selectedModels = ref<string[]>([])
|
||||||
|
const selectedUseCases = ref<string[]>([])
|
||||||
|
const selectedLicenses = ref<string[]>([])
|
||||||
|
const sortBy = ref<
|
||||||
|
| 'default'
|
||||||
|
| 'alphabetical'
|
||||||
|
| 'newest'
|
||||||
|
| 'vram-low-to-high'
|
||||||
|
| 'model-size-low-to-high'
|
||||||
|
>('newest')
|
||||||
|
|
||||||
const templatesArray = computed(() => {
|
const templatesArray = computed(() => {
|
||||||
const templateData = 'value' in templates ? templates.value : templates
|
const templateData = 'value' in templates ? templates.value : templates
|
||||||
return Array.isArray(templateData) ? templateData : []
|
return Array.isArray(templateData) ? templateData : []
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredTemplates = computed(() => {
|
// Fuse.js configuration for fuzzy search
|
||||||
const templateData = templatesArray.value
|
const fuseOptions = {
|
||||||
if (templateData.length === 0) {
|
keys: [
|
||||||
return []
|
{ name: 'name', weight: 0.3 },
|
||||||
|
{ name: 'title', weight: 0.3 },
|
||||||
|
{ name: 'description', weight: 0.2 },
|
||||||
|
{ name: 'tags', weight: 0.1 },
|
||||||
|
{ name: 'models', weight: 0.1 }
|
||||||
|
],
|
||||||
|
threshold: 0.4,
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions))
|
||||||
|
|
||||||
|
const availableModels = computed(() => {
|
||||||
|
const modelSet = new Set<string>()
|
||||||
|
templatesArray.value.forEach((template) => {
|
||||||
|
if (Array.isArray(template.models)) {
|
||||||
|
template.models.forEach((model) => modelSet.add(model))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(modelSet).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableUseCases = computed(() => {
|
||||||
|
const tagSet = new Set<string>()
|
||||||
|
templatesArray.value.forEach((template) => {
|
||||||
|
if (template.tags && Array.isArray(template.tags)) {
|
||||||
|
template.tags.forEach((tag) => tagSet.add(tag))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(tagSet).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableLicenses = computed(() => {
|
||||||
|
return ['Open Source', 'Closed Source (API Nodes)']
|
||||||
|
})
|
||||||
|
|
||||||
|
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||||
|
|
||||||
|
const filteredBySearch = computed(() => {
|
||||||
|
if (!debouncedSearchQuery.value.trim()) {
|
||||||
|
return templatesArray.value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!searchQuery.value.trim()) {
|
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||||
return templateData
|
return results.map((result) => result.item)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredByModels = computed(() => {
|
||||||
|
if (selectedModels.value.length === 0) {
|
||||||
|
return filteredBySearch.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = searchQuery.value.toLowerCase().trim()
|
return filteredBySearch.value.filter((template) => {
|
||||||
return templateData.filter((template) => {
|
if (!template.models || !Array.isArray(template.models)) {
|
||||||
const searchableText = [
|
return false
|
||||||
template.name,
|
}
|
||||||
template.description,
|
return selectedModels.value.some((selectedModel) =>
|
||||||
template.sourceModule
|
template.models?.includes(selectedModel)
|
||||||
]
|
)
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
|
|
||||||
return searchableText.includes(query)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredByUseCases = computed(() => {
|
||||||
|
if (selectedUseCases.value.length === 0) {
|
||||||
|
return filteredByModels.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredByModels.value.filter((template) => {
|
||||||
|
if (!template.tags || !Array.isArray(template.tags)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return selectedUseCases.value.some((selectedTag) =>
|
||||||
|
template.tags?.includes(selectedTag)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredByLicenses = computed(() => {
|
||||||
|
if (selectedLicenses.value.length === 0) {
|
||||||
|
return filteredByUseCases.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredByUseCases.value.filter((template) => {
|
||||||
|
// Check if template has API in its tags or name (indicating it's a closed source API node)
|
||||||
|
const isApiTemplate =
|
||||||
|
template.tags?.includes('API') ||
|
||||||
|
template.name?.toLowerCase().includes('api_')
|
||||||
|
|
||||||
|
return selectedLicenses.value.some((selectedLicense) => {
|
||||||
|
if (selectedLicense === 'Closed Source (API Nodes)') {
|
||||||
|
return isApiTemplate
|
||||||
|
} else if (selectedLicense === 'Open Source') {
|
||||||
|
return !isApiTemplate
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedTemplates = computed(() => {
|
||||||
|
const templates = [...filteredByLicenses.value]
|
||||||
|
|
||||||
|
switch (sortBy.value) {
|
||||||
|
case 'alphabetical':
|
||||||
|
return templates.sort((a, b) => {
|
||||||
|
const nameA = a.title || a.name || ''
|
||||||
|
const nameB = b.title || b.name || ''
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
|
case 'newest':
|
||||||
|
return templates.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()
|
||||||
|
})
|
||||||
|
case 'vram-low-to-high':
|
||||||
|
// TODO: Implement VRAM sorting when VRAM data is available
|
||||||
|
// For now, keep original order
|
||||||
|
return templates
|
||||||
|
case 'model-size-low-to-high':
|
||||||
|
return templates.sort((a: any, b: any) => {
|
||||||
|
const sizeA =
|
||||||
|
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
|
||||||
|
const sizeB =
|
||||||
|
typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY
|
||||||
|
if (sizeA === sizeB) return 0
|
||||||
|
return sizeA - sizeB
|
||||||
|
})
|
||||||
|
case 'default':
|
||||||
|
default:
|
||||||
|
// Keep original order (default order)
|
||||||
|
return templates
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTemplates = computed(() => sortedTemplates.value)
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
|
selectedModels.value = []
|
||||||
|
selectedUseCases.value = []
|
||||||
|
selectedLicenses.value = []
|
||||||
|
sortBy.value = 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeModelFilter = (model: string) => {
|
||||||
|
selectedModels.value = selectedModels.value.filter((m) => m !== model)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUseCaseFilter = (tag: string) => {
|
||||||
|
selectedUseCases.value = selectedUseCases.value.filter((t) => t !== tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLicenseFilter = (license: string) => {
|
||||||
|
selectedLicenses.value = selectedLicenses.value.filter((l) => l !== license)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||||
|
const totalCount = computed(() => templatesArray.value.length)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// State
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
selectedModels,
|
||||||
|
selectedUseCases,
|
||||||
|
selectedLicenses,
|
||||||
|
sortBy,
|
||||||
|
|
||||||
|
// Computed
|
||||||
filteredTemplates,
|
filteredTemplates,
|
||||||
|
availableModels,
|
||||||
|
availableUseCases,
|
||||||
|
availableLicenses,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
resetFilters
|
totalCount,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
resetFilters,
|
||||||
|
removeModelFilter,
|
||||||
|
removeUseCaseFilter,
|
||||||
|
removeLicenseFilter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/composables/useWorkflowTemplateSelectorDialog.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
|
||||||
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
|
const DIALOG_KEY = 'global-workflow-template-selector'
|
||||||
|
|
||||||
|
export const useWorkflowTemplateSelectorDialog = () => {
|
||||||
|
const dialogService = useDialogService()
|
||||||
|
const dialogStore = useDialogStore()
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
dialogService.showLayoutDialog({
|
||||||
|
key: DIALOG_KEY,
|
||||||
|
component: WorkflowTemplateSelectorDialog,
|
||||||
|
props: {
|
||||||
|
onClose: hide
|
||||||
|
},
|
||||||
|
dialogComponentProps: {
|
||||||
|
pt: {
|
||||||
|
content: { class: '!px-0 overflow-hidden h-full !py-0' },
|
||||||
|
root: {
|
||||||
|
style:
|
||||||
|
'width: 90vw; height: 85vh; max-width: 1400px; display: flex;'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
show,
|
||||||
|
hide
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,7 @@
|
|||||||
"nodesRunning": "nodes running",
|
"nodesRunning": "nodes running",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"moreWorkflows": "More workflows",
|
"moreWorkflows": "More workflows",
|
||||||
|
"seeTutorial": "See a tutorial",
|
||||||
"nodeRenderError": "Node Render Error",
|
"nodeRenderError": "Node Render Error",
|
||||||
"nodeContentError": "Node Content Error",
|
"nodeContentError": "Node Content Error",
|
||||||
"nodeHeaderError": "Node Header Error",
|
"nodeHeaderError": "Node Header Error",
|
||||||
@@ -685,6 +686,7 @@
|
|||||||
"ComfyUI Examples": "ComfyUI Examples",
|
"ComfyUI Examples": "ComfyUI Examples",
|
||||||
"Custom Nodes": "Custom Nodes",
|
"Custom Nodes": "Custom Nodes",
|
||||||
"Basics": "Basics",
|
"Basics": "Basics",
|
||||||
|
"GettingStarted": "Getting Started",
|
||||||
"Flux": "Flux",
|
"Flux": "Flux",
|
||||||
"ControlNet": "ControlNet",
|
"ControlNet": "ControlNet",
|
||||||
"Upscaling": "Upscaling",
|
"Upscaling": "Upscaling",
|
||||||
@@ -693,6 +695,7 @@
|
|||||||
"Area Composition": "Area Composition",
|
"Area Composition": "Area Composition",
|
||||||
"3D": "3D",
|
"3D": "3D",
|
||||||
"Audio": "Audio",
|
"Audio": "Audio",
|
||||||
|
"LLMs": "LLMs",
|
||||||
"Image API": "Image API",
|
"Image API": "Image API",
|
||||||
"Video API": "Video API",
|
"Video API": "Video API",
|
||||||
"LLM API": "LLM API",
|
"LLM API": "LLM API",
|
||||||
@@ -1001,6 +1004,24 @@
|
|||||||
"audio_ace_step_1_t2a_song": "ACE Step v1 Text to Song",
|
"audio_ace_step_1_t2a_song": "ACE Step v1 Text to Song",
|
||||||
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M Editing"
|
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M Editing"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"categories": "Categories",
|
||||||
|
"resetFilters": "Clear Filters",
|
||||||
|
"sorting": "Sort by",
|
||||||
|
"activeFilters": "Filters:",
|
||||||
|
"loading": "Loading templates...",
|
||||||
|
"noResults": "No templates found",
|
||||||
|
"noResultsHint": "Try adjusting your search or filters",
|
||||||
|
"modelFilter": "Model Filter",
|
||||||
|
"modelsSelected": "{count} Models",
|
||||||
|
"useCasesSelected": "{count} Use Cases",
|
||||||
|
"licensesSelected": "{count} Licenses",
|
||||||
|
"resultsCount": "Showing {count} of {total} templates",
|
||||||
|
"sort": {
|
||||||
|
"recommended": "Recommended",
|
||||||
|
"alphabetical": "A → Z",
|
||||||
|
"newest": "Newest",
|
||||||
|
"searchPlaceholder": "Search..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"graphCanvasMenu": {
|
"graphCanvasMenu": {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function useTemplateWorkflows() {
|
|||||||
const getTemplateThumbnailUrl = (
|
const getTemplateThumbnailUrl = (
|
||||||
template: TemplateInfo,
|
template: TemplateInfo,
|
||||||
sourceModule: string,
|
sourceModule: string,
|
||||||
index = ''
|
index = '1'
|
||||||
) => {
|
) => {
|
||||||
const basePath =
|
const basePath =
|
||||||
sourceModule === 'default'
|
sourceModule === 'default'
|
||||||
@@ -85,13 +85,12 @@ export function useTemplateWorkflows() {
|
|||||||
/**
|
/**
|
||||||
* Gets formatted template description
|
* Gets formatted template description
|
||||||
*/
|
*/
|
||||||
const getTemplateDescription = (
|
const getTemplateDescription = (template: TemplateInfo) => {
|
||||||
template: TemplateInfo,
|
return (
|
||||||
sourceModule: string
|
(template.localizedDescription || template.description)
|
||||||
) => {
|
?.replace(/[-_]/g, ' ')
|
||||||
return sourceModule === 'default'
|
.trim() ?? ''
|
||||||
? template.localizedDescription ?? ''
|
)
|
||||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { groupBy } from 'es-toolkit/compat'
|
import Fuse from 'fuse.js'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref, shallowRef } from 'vue'
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
import { st } from '@/i18n'
|
import { i18n, st } from '@/i18n'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||||
|
import { getCategoryIcon } from '@/utils/categoryIcons'
|
||||||
|
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TemplateGroup,
|
TemplateGroup,
|
||||||
TemplateInfo,
|
TemplateInfo,
|
||||||
WorkflowTemplates
|
WorkflowTemplates
|
||||||
} from '@/platform/workflow/templates/types/template'
|
} from '../types/template'
|
||||||
import { api } from '@/scripts/api'
|
|
||||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
|
||||||
|
|
||||||
const SHOULD_SORT_CATEGORIES = new Set([
|
// Enhanced template interface for easier filtering
|
||||||
// API Node templates should be strictly sorted by name to avoid any
|
interface EnhancedTemplate extends TemplateInfo {
|
||||||
// favoritism or bias towards a particular API. Other categories can
|
sourceModule: string
|
||||||
// have their ordering specified in index.json freely.
|
category?: string
|
||||||
'Image API',
|
categoryType?: string
|
||||||
'Video API'
|
categoryGroup?: string // 'GENERATION TYPE' or 'CLOSED SOURCE MODELS'
|
||||||
])
|
isEssential?: boolean
|
||||||
|
searchableText?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const useWorkflowTemplatesStore = defineStore(
|
export const useWorkflowTemplatesStore = defineStore(
|
||||||
'workflowTemplates',
|
'workflowTemplates',
|
||||||
@@ -26,36 +31,13 @@ export const useWorkflowTemplatesStore = defineStore(
|
|||||||
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
|
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
|
||||||
const isLoaded = ref(false)
|
const isLoaded = ref(false)
|
||||||
|
|
||||||
/**
|
// Store filter mappings for dynamic categories
|
||||||
* Sort a list of templates in alphabetical order by localized display name.
|
type FilterData = {
|
||||||
*/
|
category?: string
|
||||||
const sortTemplateList = (templates: TemplateInfo[]) =>
|
categoryGroup?: string
|
||||||
templates.sort((a, b) => {
|
}
|
||||||
const aName = st(
|
|
||||||
`templateWorkflows.name.${normalizeI18nKey(a.name)}`,
|
|
||||||
a.title ?? a.name
|
|
||||||
)
|
|
||||||
const bName = st(
|
|
||||||
`templateWorkflows.name.${normalizeI18nKey(b.name)}`,
|
|
||||||
b.name
|
|
||||||
)
|
|
||||||
return aName.localeCompare(bName)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
const categoryFilters = ref(new Map<string, FilterData>())
|
||||||
* Sort any template categories (grouped templates) that should be sorted.
|
|
||||||
* Leave other categories' templates in their original order specified in index.json
|
|
||||||
*/
|
|
||||||
const sortCategoryTemplates = (categories: WorkflowTemplates[]) =>
|
|
||||||
categories.map((category) => {
|
|
||||||
if (SHOULD_SORT_CATEGORIES.has(category.title)) {
|
|
||||||
return {
|
|
||||||
...category,
|
|
||||||
templates: sortTemplateList(category.templates)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return category
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add localization fields to a template.
|
* Add localization fields to a template.
|
||||||
@@ -144,12 +126,13 @@ export const useWorkflowTemplatesStore = defineStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Original grouped templates for backward compatibility
|
||||||
|
*/
|
||||||
const groupedTemplates = computed<TemplateGroup[]>(() => {
|
const groupedTemplates = computed<TemplateGroup[]>(() => {
|
||||||
// Get regular categories
|
// Get regular categories
|
||||||
const allTemplates = [
|
const allTemplates = [
|
||||||
...sortCategoryTemplates(coreTemplates.value).map(
|
...coreTemplates.value.map(localizeTemplateCategory),
|
||||||
localizeTemplateCategory
|
|
||||||
),
|
|
||||||
...Object.entries(customTemplates.value).map(
|
...Object.entries(customTemplates.value).map(
|
||||||
([moduleName, templates]) => ({
|
([moduleName, templates]) => ({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -169,38 +152,286 @@ export const useWorkflowTemplatesStore = defineStore(
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Group templates by their main category
|
// Group templates by their main category
|
||||||
const groupedByCategory = Object.entries(
|
const groupedByCategory = [
|
||||||
groupBy(allTemplates, (template) =>
|
{
|
||||||
template.moduleName === 'default'
|
label: st(
|
||||||
? st(
|
'templateWorkflows.category.ComfyUI Examples',
|
||||||
'templateWorkflows.category.ComfyUI Examples',
|
'ComfyUI Examples'
|
||||||
'ComfyUI Examples'
|
),
|
||||||
)
|
modules: [
|
||||||
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
|
createAllCategory(),
|
||||||
)
|
...allTemplates.filter((t) => t.moduleName === 'default')
|
||||||
).map(([label, modules]) => ({ label, modules }))
|
]
|
||||||
|
},
|
||||||
|
...(Object.keys(customTemplates.value).length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: st(
|
||||||
|
'templateWorkflows.category.Custom Nodes',
|
||||||
|
'Custom Nodes'
|
||||||
|
),
|
||||||
|
modules: allTemplates.filter((t) => t.moduleName !== 'default')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
|
||||||
// Insert the "All" category at the top of the "ComfyUI Examples" group
|
return groupedByCategory
|
||||||
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
|
})
|
||||||
(group) =>
|
|
||||||
group.label ===
|
/**
|
||||||
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
* Enhanced templates with proper categorization for filtering
|
||||||
|
*/
|
||||||
|
const enhancedTemplates = computed<EnhancedTemplate[]>(() => {
|
||||||
|
const allTemplates: EnhancedTemplate[] = []
|
||||||
|
|
||||||
|
// Process core templates
|
||||||
|
coreTemplates.value.forEach((category) => {
|
||||||
|
category.templates.forEach((template) => {
|
||||||
|
const enhancedTemplate: EnhancedTemplate = {
|
||||||
|
...template,
|
||||||
|
sourceModule: category.moduleName,
|
||||||
|
category: category.title,
|
||||||
|
categoryType: category.type,
|
||||||
|
categoryGroup: category.category,
|
||||||
|
isEssential: category.isEssential,
|
||||||
|
searchableText: [
|
||||||
|
template.title || template.name,
|
||||||
|
template.description || '',
|
||||||
|
category.title,
|
||||||
|
...(template.tags || []),
|
||||||
|
...(template.models || [])
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
allTemplates.push(enhancedTemplate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process custom templates
|
||||||
|
Object.entries(customTemplates.value).forEach(
|
||||||
|
([moduleName, templates]) => {
|
||||||
|
templates.forEach((name) => {
|
||||||
|
const enhancedTemplate: EnhancedTemplate = {
|
||||||
|
name,
|
||||||
|
title: name,
|
||||||
|
description: name,
|
||||||
|
mediaType: 'image',
|
||||||
|
mediaSubtype: 'jpg',
|
||||||
|
sourceModule: moduleName,
|
||||||
|
category: 'Extensions',
|
||||||
|
categoryType: 'extension',
|
||||||
|
searchableText: `${name} ${moduleName} extension`
|
||||||
|
}
|
||||||
|
allTemplates.push(enhancedTemplate)
|
||||||
|
})
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (comfyExamplesGroupIndex !== -1) {
|
return allTemplates
|
||||||
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
|
})
|
||||||
createAllCategory()
|
|
||||||
|
/**
|
||||||
|
* Fuse.js instance for advanced template searching and filtering
|
||||||
|
*/
|
||||||
|
const templateFuse = computed(() => {
|
||||||
|
const fuseOptions = {
|
||||||
|
keys: [
|
||||||
|
{ name: 'searchableText', weight: 0.4 },
|
||||||
|
{ name: 'title', weight: 0.3 },
|
||||||
|
{ name: 'name', weight: 0.2 },
|
||||||
|
{ name: 'tags', weight: 0.1 }
|
||||||
|
],
|
||||||
|
threshold: 0.3,
|
||||||
|
includeScore: true
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Fuse(enhancedTemplates.value, fuseOptions)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter templates by category ID using stored filter mappings
|
||||||
|
*/
|
||||||
|
const filterTemplatesByCategory = (categoryId: string) => {
|
||||||
|
if (categoryId === 'all') {
|
||||||
|
return enhancedTemplates.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryId === 'basics') {
|
||||||
|
// Filter for templates from categories marked as essential
|
||||||
|
return enhancedTemplates.value.filter((t) => t.isEssential)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle extension-specific filters
|
||||||
|
if (categoryId.startsWith('extension-')) {
|
||||||
|
const moduleName = categoryId.replace('extension-', '')
|
||||||
|
return enhancedTemplates.value.filter(
|
||||||
|
(t) => t.sourceModule === moduleName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return groupedByCategory
|
// Look up the filter from our stored mappings
|
||||||
|
const filter = categoryFilters.value.get(categoryId)
|
||||||
|
if (!filter) {
|
||||||
|
return enhancedTemplates.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the filter
|
||||||
|
return enhancedTemplates.value.filter((template) => {
|
||||||
|
if (filter.category && template.category !== filter.category) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
filter.categoryGroup &&
|
||||||
|
template.categoryGroup !== filter.categoryGroup
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New navigation structure dynamically built from JSON categories
|
||||||
|
*/
|
||||||
|
const navGroupedTemplates = computed<(NavItemData | NavGroupData)[]>(() => {
|
||||||
|
if (!isLoaded.value) return []
|
||||||
|
|
||||||
|
const items: (NavItemData | NavGroupData)[] = []
|
||||||
|
|
||||||
|
// Clear and rebuild filter mappings
|
||||||
|
categoryFilters.value.clear()
|
||||||
|
|
||||||
|
// 1. All Templates - always first
|
||||||
|
items.push({
|
||||||
|
id: 'all',
|
||||||
|
label: st('templateWorkflows.category.All', 'All Templates'),
|
||||||
|
icon: getCategoryIcon('all')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Basics (isEssential categories) - always second if it exists
|
||||||
|
let gettingStartedText = 'Getting Started'
|
||||||
|
const essentialCat = coreTemplates.value.find(
|
||||||
|
(cat) => cat.isEssential && cat.templates.length > 0
|
||||||
|
)
|
||||||
|
const hasEssentialCategories = Boolean(essentialCat)
|
||||||
|
|
||||||
|
if (essentialCat) {
|
||||||
|
gettingStartedText = essentialCat.title
|
||||||
|
}
|
||||||
|
if (hasEssentialCategories) {
|
||||||
|
items.push({
|
||||||
|
id: 'basics',
|
||||||
|
label: gettingStartedText,
|
||||||
|
icon: 'icon-[lucide--graduation-cap]'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Group categories from JSON dynamically
|
||||||
|
const categoryGroups = new Map<
|
||||||
|
string,
|
||||||
|
{ title: string; items: NavItemData[] }
|
||||||
|
>()
|
||||||
|
|
||||||
|
// Process all categories from JSON
|
||||||
|
coreTemplates.value.forEach((category) => {
|
||||||
|
// Skip essential categories as they're handled as Basics
|
||||||
|
if (category.isEssential) return
|
||||||
|
|
||||||
|
const categoryGroup = category.category
|
||||||
|
const categoryIcon = category.icon
|
||||||
|
|
||||||
|
if (categoryGroup) {
|
||||||
|
if (!categoryGroups.has(categoryGroup)) {
|
||||||
|
categoryGroups.set(categoryGroup, {
|
||||||
|
title: categoryGroup,
|
||||||
|
items: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = categoryGroups.get(categoryGroup)!
|
||||||
|
|
||||||
|
// Generate unique ID for this category
|
||||||
|
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
|
||||||
|
|
||||||
|
// Store the filter mapping
|
||||||
|
categoryFilters.value.set(categoryId, {
|
||||||
|
category: category.title,
|
||||||
|
categoryGroup: categoryGroup
|
||||||
|
})
|
||||||
|
|
||||||
|
group.items.push({
|
||||||
|
id: categoryId,
|
||||||
|
label: st(
|
||||||
|
`templateWorkflows.category.${normalizeI18nKey(category.title)}`,
|
||||||
|
category.title
|
||||||
|
),
|
||||||
|
icon: categoryIcon || getCategoryIcon(category.type || 'default')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add grouped categories
|
||||||
|
categoryGroups.forEach((group, groupName) => {
|
||||||
|
if (group.items.length > 0) {
|
||||||
|
items.push({
|
||||||
|
title: st(
|
||||||
|
`templateWorkflows.category.${normalizeI18nKey(groupName)}`,
|
||||||
|
groupName
|
||||||
|
.split(' ')
|
||||||
|
.map(
|
||||||
|
(word) =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||||
|
)
|
||||||
|
.join(' ')
|
||||||
|
),
|
||||||
|
items: group.items
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Extensions - always last
|
||||||
|
const extensionCounts = enhancedTemplates.value.filter(
|
||||||
|
(t) => t.sourceModule !== 'default'
|
||||||
|
).length
|
||||||
|
|
||||||
|
if (extensionCounts > 0) {
|
||||||
|
// Get unique extension modules
|
||||||
|
const extensionModules = Array.from(
|
||||||
|
new Set(
|
||||||
|
enhancedTemplates.value
|
||||||
|
.filter((t) => t.sourceModule !== 'default')
|
||||||
|
.map((t) => t.sourceModule)
|
||||||
|
)
|
||||||
|
).sort()
|
||||||
|
|
||||||
|
const extensionItems: NavItemData[] = extensionModules.map(
|
||||||
|
(moduleName) => ({
|
||||||
|
id: `extension-${moduleName}`,
|
||||||
|
label: st(
|
||||||
|
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
|
||||||
|
moduleName
|
||||||
|
),
|
||||||
|
icon: getCategoryIcon('extensions')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
title: st('templateWorkflows.category.Extensions', 'Extensions'),
|
||||||
|
items: extensionItems,
|
||||||
|
collapsible: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadWorkflowTemplates() {
|
async function loadWorkflowTemplates() {
|
||||||
try {
|
try {
|
||||||
if (!isLoaded.value) {
|
if (!isLoaded.value) {
|
||||||
customTemplates.value = await api.getWorkflowTemplates()
|
customTemplates.value = await api.getWorkflowTemplates()
|
||||||
coreTemplates.value = await api.getCoreWorkflowTemplates()
|
const locale = i18n.global.locale.value
|
||||||
|
coreTemplates.value = await api.getCoreWorkflowTemplates(locale)
|
||||||
isLoaded.value = true
|
isLoaded.value = true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -210,6 +441,10 @@ export const useWorkflowTemplatesStore = defineStore(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
groupedTemplates,
|
groupedTemplates,
|
||||||
|
navGroupedTemplates,
|
||||||
|
enhancedTemplates,
|
||||||
|
templateFuse,
|
||||||
|
filterTemplatesByCategory,
|
||||||
isLoaded,
|
isLoaded,
|
||||||
loadWorkflowTemplates
|
loadWorkflowTemplates
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,25 @@ export interface TemplateInfo {
|
|||||||
description: string
|
description: string
|
||||||
localizedTitle?: string
|
localizedTitle?: string
|
||||||
localizedDescription?: string
|
localizedDescription?: string
|
||||||
|
isEssential?: boolean
|
||||||
sourceModule?: string
|
sourceModule?: string
|
||||||
|
tags?: string[]
|
||||||
|
models?: string[]
|
||||||
|
date?: string
|
||||||
|
useCase?: string
|
||||||
|
license?: string
|
||||||
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowTemplates {
|
export interface WorkflowTemplates {
|
||||||
moduleName: string
|
moduleName: string
|
||||||
templates: TemplateInfo[]
|
templates: TemplateInfo[]
|
||||||
title: string
|
title: string
|
||||||
|
localizedTitle?: string
|
||||||
|
category?: string
|
||||||
|
type?: string
|
||||||
|
icon?: string
|
||||||
|
isEssential?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateGroup {
|
export interface TemplateGroup {
|
||||||
|
|||||||
@@ -603,11 +603,28 @@ export class ComfyApi extends EventTarget {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the index of core workflow templates.
|
* Gets the index of core workflow templates.
|
||||||
|
* @param locale Optional locale code (e.g., 'en', 'fr', 'zh') to load localized templates
|
||||||
*/
|
*/
|
||||||
async getCoreWorkflowTemplates(): Promise<WorkflowTemplates[]> {
|
async getCoreWorkflowTemplates(
|
||||||
const res = await axios.get(this.fileURL('/templates/index.json'))
|
locale?: string
|
||||||
const contentType = res.headers['content-type']
|
): Promise<WorkflowTemplates[]> {
|
||||||
return contentType?.includes('application/json') ? res.data : []
|
const fileName =
|
||||||
|
locale && locale !== 'en' ? `index.${locale}.json` : 'index.json'
|
||||||
|
try {
|
||||||
|
const res = await axios.get(this.fileURL(`/templates/${fileName}`))
|
||||||
|
const contentType = res.headers['content-type']
|
||||||
|
return contentType?.includes('application/json') ? res.data : []
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to default English version if localized version doesn't exist
|
||||||
|
if (locale && locale !== 'en') {
|
||||||
|
console.warn(
|
||||||
|
`Localized templates for '${locale}' not found, falling back to English`
|
||||||
|
)
|
||||||
|
return this.getCoreWorkflowTemplates()
|
||||||
|
}
|
||||||
|
console.error('Error loading core workflow templates:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsD
|
|||||||
import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue'
|
import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue'
|
||||||
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
|
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
|
||||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||||
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
|
|
||||||
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
|
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
||||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||||
@@ -111,23 +109,6 @@ export const useDialogService = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTemplateWorkflowsDialog(
|
|
||||||
props: InstanceType<typeof TemplateWorkflowsContent>['$props'] = {}
|
|
||||||
) {
|
|
||||||
dialogStore.showDialog({
|
|
||||||
key: 'global-template-workflows',
|
|
||||||
title: t('templateWorkflows.title'),
|
|
||||||
component: TemplateWorkflowsContent,
|
|
||||||
headerComponent: TemplateWorkflowsDialogHeader,
|
|
||||||
dialogComponentProps: {
|
|
||||||
pt: {
|
|
||||||
content: { class: 'px-0! overflow-y-hidden' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
props
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function showManagerDialog(
|
function showManagerDialog(
|
||||||
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
|
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
|
||||||
) {
|
) {
|
||||||
@@ -155,30 +136,6 @@ export const useDialogService = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showManagerProgressDialog(options?: {
|
|
||||||
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
|
|
||||||
}) {
|
|
||||||
return dialogStore.showDialog({
|
|
||||||
key: 'global-manager-progress-dialog',
|
|
||||||
component: ManagerProgressDialogContent,
|
|
||||||
headerComponent: ManagerProgressHeader,
|
|
||||||
footerComponent: ManagerProgressFooter,
|
|
||||||
props: options?.props,
|
|
||||||
priority: 2,
|
|
||||||
dialogComponentProps: {
|
|
||||||
closable: false,
|
|
||||||
modal: false,
|
|
||||||
position: 'bottom',
|
|
||||||
pt: {
|
|
||||||
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
|
|
||||||
content: { class: 'p-0!' },
|
|
||||||
header: { class: 'p-0! border-none' },
|
|
||||||
footer: { class: 'p-0! border-none' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseError(error: Error) {
|
function parseError(error: Error) {
|
||||||
const filename =
|
const filename =
|
||||||
'fileName' in error
|
'fileName' in error
|
||||||
@@ -235,6 +192,30 @@ export const useDialogService = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showManagerProgressDialog(options?: {
|
||||||
|
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
|
||||||
|
}) {
|
||||||
|
return dialogStore.showDialog({
|
||||||
|
key: 'global-manager-progress-dialog',
|
||||||
|
component: ManagerProgressDialogContent,
|
||||||
|
headerComponent: ManagerProgressHeader,
|
||||||
|
footerComponent: ManagerProgressFooter,
|
||||||
|
props: options?.props,
|
||||||
|
priority: 2,
|
||||||
|
dialogComponentProps: {
|
||||||
|
closable: false,
|
||||||
|
modal: false,
|
||||||
|
position: 'bottom',
|
||||||
|
pt: {
|
||||||
|
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
|
||||||
|
content: { class: 'p-0!' },
|
||||||
|
header: { class: 'p-0! border-none' },
|
||||||
|
footer: { class: 'p-0! border-none' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a dialog requiring sign in for API nodes
|
* Shows a dialog requiring sign in for API nodes
|
||||||
* @returns Promise that resolves to true if user clicks login, false if cancelled
|
* @returns Promise that resolves to true if user clicks login, false if cancelled
|
||||||
@@ -511,16 +492,15 @@ export const useDialogService = () => {
|
|||||||
showSettingsDialog,
|
showSettingsDialog,
|
||||||
showAboutDialog,
|
showAboutDialog,
|
||||||
showExecutionErrorDialog,
|
showExecutionErrorDialog,
|
||||||
showTemplateWorkflowsDialog,
|
|
||||||
showManagerDialog,
|
showManagerDialog,
|
||||||
showManagerProgressDialog,
|
showManagerProgressDialog,
|
||||||
showErrorDialog,
|
|
||||||
showApiNodesSignInDialog,
|
showApiNodesSignInDialog,
|
||||||
showSignInDialog,
|
showSignInDialog,
|
||||||
showTopUpCreditsDialog,
|
showTopUpCreditsDialog,
|
||||||
showUpdatePasswordDialog,
|
showUpdatePasswordDialog,
|
||||||
showExtensionDialog,
|
showExtensionDialog,
|
||||||
prompt,
|
prompt,
|
||||||
|
showErrorDialog,
|
||||||
confirm,
|
confirm,
|
||||||
toggleManagerDialog,
|
toggleManagerDialog,
|
||||||
toggleManagerProgressDialog,
|
toggleManagerProgressDialog,
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ export interface NavItemData {
|
|||||||
export interface NavGroupData {
|
export interface NavGroupData {
|
||||||
title: string
|
title: string
|
||||||
items: NavItemData[]
|
items: NavItemData[]
|
||||||
|
icon?: string
|
||||||
|
collapsible?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/utils/categoryIcons.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Maps category IDs to their corresponding Lucide icon classes
|
||||||
|
*/
|
||||||
|
export const getCategoryIcon = (categoryId: string): string => {
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
// Main categories
|
||||||
|
all: 'icon-[lucide--list]',
|
||||||
|
'getting-started': 'icon-[lucide--graduation-cap]',
|
||||||
|
|
||||||
|
// Generation types
|
||||||
|
'generation-image': 'icon-[lucide--image]',
|
||||||
|
image: 'icon-[lucide--image]',
|
||||||
|
'generation-video': 'icon-[lucide--film]',
|
||||||
|
video: 'icon-[lucide--film]',
|
||||||
|
'generation-3d': 'icon-[lucide--box]',
|
||||||
|
'3d': 'icon-[lucide--box]',
|
||||||
|
'generation-audio': 'icon-[lucide--volume-2]',
|
||||||
|
audio: 'icon-[lucide--volume-2]',
|
||||||
|
'generation-llm': 'icon-[lucide--message-square-text]',
|
||||||
|
|
||||||
|
// API and models
|
||||||
|
'api-nodes': 'icon-[lucide--hand-coins]',
|
||||||
|
'closed-models': 'icon-[lucide--hand-coins]',
|
||||||
|
|
||||||
|
// LLMs and AI
|
||||||
|
llm: 'icon-[lucide--message-square-text]',
|
||||||
|
llms: 'icon-[lucide--message-square-text]',
|
||||||
|
'llm-api': 'icon-[lucide--message-square-text]',
|
||||||
|
|
||||||
|
// Performance and hardware
|
||||||
|
'small-models': 'icon-[lucide--zap]',
|
||||||
|
performance: 'icon-[lucide--zap]',
|
||||||
|
'mac-compatible': 'icon-[lucide--command]',
|
||||||
|
'runs-on-mac': 'icon-[lucide--command]',
|
||||||
|
|
||||||
|
// Training
|
||||||
|
'lora-training': 'icon-[lucide--dumbbell]',
|
||||||
|
training: 'icon-[lucide--dumbbell]',
|
||||||
|
|
||||||
|
// Extensions and tools
|
||||||
|
extensions: 'icon-[lucide--puzzle]',
|
||||||
|
tools: 'icon-[lucide--wrench]',
|
||||||
|
|
||||||
|
// Fallbacks for common patterns
|
||||||
|
upscaling: 'icon-[lucide--maximize-2]',
|
||||||
|
controlnet: 'icon-[lucide--sliders-horizontal]',
|
||||||
|
'area-composition': 'icon-[lucide--layout-grid]'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return mapped icon or fallback to folder
|
||||||
|
return iconMap[categoryId.toLowerCase()] || 'icon-[lucide--folder]'
|
||||||
|
}
|
||||||
@@ -225,28 +225,22 @@ describe('useTemplateWorkflows', () => {
|
|||||||
const { getTemplateDescription } = useTemplateWorkflows()
|
const { getTemplateDescription } = useTemplateWorkflows()
|
||||||
|
|
||||||
// Default template with localized description
|
// Default template with localized description
|
||||||
const descWithLocalized = getTemplateDescription(
|
const descWithLocalized = getTemplateDescription({
|
||||||
{
|
name: 'test',
|
||||||
name: 'test',
|
localizedDescription: 'Localized Description',
|
||||||
localizedDescription: 'Localized Description',
|
mediaType: 'image',
|
||||||
mediaType: 'image',
|
mediaSubtype: 'jpg',
|
||||||
mediaSubtype: 'jpg',
|
description: 'Test'
|
||||||
description: 'Test'
|
})
|
||||||
},
|
|
||||||
'default'
|
|
||||||
)
|
|
||||||
expect(descWithLocalized).toBe('Localized Description')
|
expect(descWithLocalized).toBe('Localized Description')
|
||||||
|
|
||||||
// Custom template with description
|
// Custom template with description
|
||||||
const customDesc = getTemplateDescription(
|
const customDesc = getTemplateDescription({
|
||||||
{
|
name: 'test',
|
||||||
name: 'test',
|
description: 'custom-template_description',
|
||||||
description: 'custom-template_description',
|
mediaType: 'image',
|
||||||
mediaType: 'image',
|
mediaSubtype: 'jpg'
|
||||||
mediaSubtype: 'jpg'
|
})
|
||||||
},
|
|
||||||
'custom-module'
|
|
||||||
)
|
|
||||||
expect(customDesc).toBe('custom template description')
|
expect(customDesc).toBe('custom template description')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||