Compare commits

...

9 Commits

Author SHA1 Message Date
John Haugeland
bf63a5cc71 feat: add VRAM requirement estimation for workflow templates
Add a frontend heuristic that estimates peak VRAM consumption by
detecting model-loading nodes in the workflow graph and summing
approximate memory costs per model category (checkpoints, LoRAs,
ControlNets, VAEs, etc.). The estimate uses only the largest base
model (checkpoint or diffusion_model) since ComfyUI offloads others,
plus all co-resident models and a flat runtime overhead.

Surfaces the estimate in three places:

1. Template publishing wizard (metadata step) — auto-detects VRAM on
   mount using the same graph traversal pattern as custom node
   detection, with a manual GB override input for fine-tuning.

2. Template marketplace cards — displays a VRAM badge in the top-left
   corner of template thumbnails using the existing SquareChip and
   CardTop slot infrastructure.

3. Workflow editor — floating indicator in the bottom-right of the
   graph canvas showing estimated VRAM for the current workflow.

Bumps version to 1.46.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:09:32 -08:00
John Haugeland
8361122586 feat: developer profile dashboard, preview asset uploads, and publishing refinements
Add developer profile dialog with editable handle lookup, download
history chart (Chart.js with weekly/monthly downsampling), star ratings,
review cards, and template list. The handle input allows browsing other
developers' profiles via debounced stub service dispatch.

Add preview asset upload system for template publishing step 4:
thumbnail, before/after comparison, workflow graph, optional video, and
gallery of up to 6 images. Uploads are cached in-memory as blob URLs
via a module-level singleton composable (useTemplatePreviewAssets).

Add reusable TemplateAssetUploadZone component, PreviewField/
PreviewSection sub-components, and templateScreenshotRenderer for
generating workflow graph previews from LGraph instances.

Internationalize command labels, add workflow actions menu entry for
template publishing, and extend marketplace types with CachedAsset,
DownloadHistoryEntry, and developer profile models.

Bump version to 1.45.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:44:48 -08:00
John Haugeland
55dea32e00 feat: add "Similar to Current" sort option to template selector
Wire the existing templateSimilarity module into the template selector
dialog so users can rank templates by how closely they match the nodes
in their active workflow. The sort extracts node types from the current
graph and scores each template using weighted Jaccard similarity across
categories, tags, models, and required nodes.

- Add 'similar-to-current' to sort dropdown, schema enum, type union,
  and telemetry type
- Export computeSimilarity from templateSimilarity.ts for direct use
- Add i18n key templateWorkflows.sort.similarToCurrent
- Bump version to 1.44.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:42:31 -08:00
John Haugeland
07d49cbe64 feat: template publishing dialog UI refinements and custom node detection
- Remove step panel titles; keep descriptions only on Landing, Submit,
  and Complete steps
- Move categories and tags controls from Metadata to CategoryAndTagging
  step panel
- Add auto-detection of custom nodes from current workflow graph using
  nodeDefStore with searchable typeahead input for manual additions
- Make description panel side-by-side layout (editor left, preview right)
- Replace title FormItem with wide text input (100em)
- Remove save draft button from dialog header
- Add mr-6 spacing between navigation buttons and close button
- Alphabetically sort category checkboxes
- Fix tag dropdown background transparency and overflow clipping
- Left-align form label column with consistent w-28 shrink-0
- Make difficulty radio button borders thicker (border-2)
- Fix unused useI18n imports in Preview and PreviewGeneration steps
- Add tests for custom node detection, searchable suggestions, and
  category/tag functionality (40 tests across 5 files)
- Bump version to 1.43.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:40:40 -08:00
John Haugeland
fdd963a630 feat: template publishing dialog, stepper, and step components
Rename template marketplace to template publishing throughout:
- Replace useTemplateMarketplaceDialog with useTemplatePublishingDialog
- Update core command from Comfy.ShowTemplateMarketplace to
  Comfy.ShowTemplatePublishing
- Update workflow actions menu label and command reference

Add multi-step publishing dialog infrastructure:
- TemplatePublishingDialog.vue with step-based navigation
- TemplatePublishingStepperNav.vue for step progress indicator
- useTemplatePublishingStepper composable managing step state,
  navigation, and validation
- Step components for each phase: landing, metadata, description,
  preview generation, category/tagging, preview, submission, complete
- StepTemplatePublishingMetadata with form fields for title, category,
  tags, difficulty, and license selection
- StepTemplatePublishingDescription with markdown editor and live
  preview via vue-i18n

Add comprehensive i18n entries for all publishing steps, form labels,
difficulty levels, license types, and category names.

Add tests for dialog lifecycle, stepper navigation/validation, metadata
form interaction, and description editing.

Bump version to 1.42.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:30:25 -08:00
John Haugeland
b638e6a577 publish storage for temporaries, some i18n keys, an actions menu stub, the beginnings of a component dialog with a pure vue stub, and the first core command 2026-02-24 10:48:57 -08:00
John Haugeland
c7409b6830 onboard types from challenge definition 2026-02-24 08:05:02 -08:00
John Haugeland
ff972fbefb added feature flag 2026-02-24 07:56:19 -08:00
John Haugeland
b18fd8e57a tests for added feature flag 2026-02-24 07:55:27 -08:00
69 changed files with 8125 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.3",
"version": "1.46.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -267,6 +267,16 @@
/>
</div>
</template>
<template v-if="template.vram" #top-left>
<SquareChip
:label="formatSize(template.vram)"
:title="t('templateWorkflows.vramEstimateTooltip')"
>
<template #icon>
<i class="icon-[lucide--cpu] h-3 w-3" />
</template>
</SquareChip>
</template>
<template #bottom-right>
<template v-if="template.tags && template.tags.length > 0">
<SquareChip
@@ -387,6 +397,7 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { formatSize } from '@/utils/formatUtil'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -724,6 +735,10 @@ const sortOptions = computed(() => [
name: t('templateWorkflows.sort.recommended', 'Recommended'),
value: 'recommended'
},
{
name: t('templateWorkflows.sort.similarToCurrent', 'Similar to Current'),
value: 'similar-to-current'
},
{
name: t('templateWorkflows.sort.popular', 'Popular'),
value: 'popular'

View File

@@ -0,0 +1,367 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
DeveloperProfile,
MarketplaceTemplate,
TemplateReview,
TemplateRevenue
} from '@/types/templateMarketplace'
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
const typedActual = actual as {
watchDebounced: (...args: unknown[]) => unknown
}
return typedActual.watchDebounced(source, cb, {
...(opts as object),
debounce: 0
})
})
}
})
const stubProfile: DeveloperProfile = {
username: '@StoneCypher',
displayName: 'Stone Cypher',
avatarUrl: undefined,
bannerUrl: undefined,
bio: 'Workflow designer',
isVerified: true,
monetizationEnabled: true,
joinedAt: new Date('2024-03-15'),
dependencies: 371,
totalDownloads: 1000,
totalFavorites: 50,
averageRating: 4.2,
templateCount: 2
}
const stubReviews: TemplateReview[] = [
{
id: 'rev-1',
authorName: 'Reviewer',
rating: 4.5,
text: 'Great work!',
createdAt: new Date('2025-10-01'),
templateId: 'tpl-1'
}
]
const stubTemplate: MarketplaceTemplate = {
id: 'tpl-1',
title: 'Test Template',
description: 'Desc',
shortDescription: 'Short',
author: {
id: 'usr-1',
name: 'Stone Cypher',
isVerified: true,
profileUrl: '/u'
},
categories: [],
tags: [],
difficulty: 'beginner',
requiredModels: [],
requiredNodes: [],
requiresCustomNodes: [],
vramRequirement: 0,
thumbnail: '',
gallery: [],
workflowPreview: '',
license: 'mit',
version: '1.0.0',
status: 'approved',
updatedAt: new Date(),
stats: {
downloads: 500,
favorites: 30,
rating: 4,
reviewCount: 5,
weeklyTrend: 1
}
}
const stubRevenue: TemplateRevenue[] = [
{
templateId: 'tpl-1',
totalRevenue: 5000,
monthlyRevenue: 500,
currency: 'USD'
}
]
const mockService = vi.hoisted(() => ({
getCurrentUsername: vi.fn(() => '@StoneCypher'),
fetchDeveloperProfile: vi.fn(() => Promise.resolve({ ...stubProfile })),
fetchDeveloperReviews: vi.fn(() => Promise.resolve([...stubReviews])),
fetchPublishedTemplates: vi.fn(() => Promise.resolve([{ ...stubTemplate }])),
fetchTemplateRevenue: vi.fn(() => Promise.resolve([...stubRevenue])),
fetchDownloadHistory: vi.fn(() => Promise.resolve([])),
unpublishTemplate: vi.fn(() => Promise.resolve()),
saveDeveloperProfile: vi.fn((p: Partial<DeveloperProfile>) =>
Promise.resolve({ ...stubProfile, ...p })
)
}))
vi.mock('@/services/developerProfileService', () => mockService)
import DeveloperProfileDialog from './DeveloperProfileDialog.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
developerProfile: {
dialogTitle: 'Developer Profile',
username: 'Username',
bio: 'Bio',
reviews: 'Reviews',
publishedTemplates: 'Published Templates',
dependencies: 'Dependencies',
totalDownloads: 'Downloads',
totalFavorites: 'Favorites',
averageRating: 'Avg. Rating',
templateCount: 'Templates',
revenue: 'Revenue',
monthlyRevenue: 'Monthly',
totalRevenue: 'Total',
noReviews: 'No reviews yet',
noTemplates: 'No published templates yet',
unpublish: 'Unpublish',
save: 'Save Profile',
saving: 'Saving...',
verified: 'Verified',
quickActions: 'Quick Actions',
bannerPlaceholder: 'Banner image',
editUsername: 'Edit username',
editBio: 'Edit bio',
lookupHandle: 'Enter developer handle\u2026',
downloads: 'Downloads',
favorites: 'Favorites',
rating: 'Rating'
}
}
}
})
function mountDialog(props?: { username?: string }) {
return mount(DeveloperProfileDialog, {
props: {
onClose: vi.fn(),
...props
},
global: {
plugins: [i18n],
stubs: {
BaseModalLayout: {
template: `
<div data-testid="modal">
<div data-testid="header"><slot name="header" /></div>
<div data-testid="header-right"><slot name="header-right-area" /></div>
<div data-testid="content"><slot name="content" /></div>
</div>
`
},
ReviewCard: {
template: '<div data-testid="review-card" />',
props: ['review']
},
TemplateListItem: {
template:
'<div data-testid="template-list-item" :data-show-revenue="showRevenue" :data-is-current-user="isCurrentUser" />',
props: ['template', 'revenue', 'showRevenue', 'isCurrentUser']
},
DownloadHistoryChart: {
template: '<div data-testid="download-history-chart" />',
props: ['entries']
},
Button: {
template: '<button><slot /></button>',
props: ['variant', 'size', 'disabled']
}
}
}
})
}
describe('DeveloperProfileDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockService.getCurrentUsername.mockReturnValue('@StoneCypher')
mockService.fetchDeveloperProfile.mockResolvedValue({ ...stubProfile })
mockService.fetchDeveloperReviews.mockResolvedValue([...stubReviews])
mockService.fetchPublishedTemplates.mockResolvedValue([{ ...stubTemplate }])
mockService.fetchTemplateRevenue.mockResolvedValue([...stubRevenue])
})
it('renders the banner section', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('[data-testid="banner-section"]').exists()).toBe(true)
})
it('shows username input when viewing own profile', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(false)
})
it('shows username text when viewing another profile', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(false)
})
it('shows bio input when viewing own profile', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('[data-testid="bio-input"]').exists()).toBe(true)
})
it('shows bio text when viewing another profile', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
expect(wrapper.find('[data-testid="bio-text"]').exists()).toBe(true)
})
it('renders review cards', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.findAll('[data-testid="review-card"]')).toHaveLength(1)
})
it('renders template list items', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.findAll('[data-testid="template-list-item"]')).toHaveLength(
1
)
})
it('passes showRevenue=true when current user with monetization', async () => {
const wrapper = mountDialog()
await flushPromises()
const item = wrapper.find('[data-testid="template-list-item"]')
expect(item.attributes('data-show-revenue')).toBe('true')
})
it('passes showRevenue=false when not current user', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
const item = wrapper.find('[data-testid="template-list-item"]')
expect(item.attributes('data-show-revenue')).toBe('false')
})
it('shows quick actions when viewing own profile', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(true)
})
it('hides quick actions when viewing another profile', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(false)
})
it('shows save button when viewing own profile', async () => {
const wrapper = mountDialog()
await flushPromises()
const headerRight = wrapper.find('[data-testid="header-right"]')
expect(headerRight.text()).toContain('Save Profile')
})
it('hides save button when viewing another profile', async () => {
const wrapper = mountDialog({ username: '@OtherUser' })
await flushPromises()
const headerRight = wrapper.find('[data-testid="header-right"]')
expect(headerRight.text()).not.toContain('Save Profile')
})
it('renders summary stats', async () => {
const wrapper = mountDialog()
await flushPromises()
const stats = wrapper.find('[data-testid="summary-stats"]')
expect(stats.exists()).toBe(true)
expect(stats.text()).toContain('371')
expect(stats.text()).toContain('1,000')
expect(stats.text()).toContain('50')
})
it('renders the handle input with the default username', async () => {
const wrapper = mountDialog()
await flushPromises()
const handleInput = wrapper.find('[data-testid="handle-input"]')
expect(handleInput.exists()).toBe(true)
expect((handleInput.element as HTMLInputElement).value).toBe('@StoneCypher')
})
it('reloads data when the handle input changes', async () => {
const otherProfile: DeveloperProfile = {
...stubProfile,
username: '@OtherDev',
displayName: 'Other Dev',
bio: 'Another developer',
isVerified: false,
monetizationEnabled: false,
totalDownloads: 42
}
const wrapper = mountDialog()
await flushPromises()
// Initial load
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith(
'@StoneCypher'
)
vi.clearAllMocks()
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
mockService.fetchDeveloperReviews.mockResolvedValue([])
mockService.fetchPublishedTemplates.mockResolvedValue([])
const handleInput = wrapper.find('[data-testid="handle-input"]')
await handleInput.setValue('@OtherDev')
await flushPromises()
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith('@OtherDev')
expect(wrapper.find('[data-testid="username-text"]').text()).toBe(
'Other Dev'
)
})
it('clears revenue when switching to a non-current-user handle', async () => {
const wrapper = mountDialog()
await flushPromises()
// Revenue was loaded for current user
expect(mockService.fetchTemplateRevenue).toHaveBeenCalled()
vi.clearAllMocks()
const otherProfile: DeveloperProfile = {
...stubProfile,
username: '@Someone',
monetizationEnabled: false
}
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
mockService.fetchDeveloperReviews.mockResolvedValue([])
mockService.fetchPublishedTemplates.mockResolvedValue([])
const handleInput = wrapper.find('[data-testid="handle-input"]')
await handleInput.setValue('@Someone')
await flushPromises()
// Revenue should NOT be fetched for other user
expect(mockService.fetchTemplateRevenue).not.toHaveBeenCalled()
// showRevenue should be false
const item = wrapper.find('[data-testid="template-list-item"]')
expect(item.exists()).toBe(false)
})
})

View File

@@ -0,0 +1,322 @@
<template>
<BaseModalLayout :content-title="t('developerProfile.dialogTitle')" size="sm">
<template #header>
<input
v-model="viewedUsername"
type="text"
:placeholder="t('developerProfile.lookupHandle')"
class="h-8 w-48 rounded border border-border-default bg-secondary-background px-2 text-sm text-muted-foreground focus:outline-none"
data-testid="handle-input"
/>
</template>
<template v-if="isCurrentUser" #header-right-area>
<div class="mr-6">
<Button size="lg" :disabled="isSaving" @click="saveProfile">
{{
isSaving ? t('developerProfile.saving') : t('developerProfile.save')
}}
</Button>
</div>
</template>
<template #content>
<div class="flex flex-col gap-6">
<!-- Banner Image -->
<div
class="h-48 w-full overflow-hidden rounded-lg bg-secondary-background"
data-testid="banner-section"
>
<img
v-if="profile?.bannerUrl"
:src="profile.bannerUrl"
:alt="t('developerProfile.bannerPlaceholder')"
class="size-full object-cover"
/>
<div v-else class="flex size-full items-center justify-center">
<i
class="icon-[lucide--image] size-10 text-muted-foreground opacity-40"
/>
</div>
</div>
<!-- Avatar + Username + Bio -->
<div class="flex items-start gap-4" data-testid="identity-section">
<div
class="flex size-16 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
>
<i class="icon-[lucide--user] size-8 text-muted-foreground" />
</div>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div class="flex items-center gap-2">
<template v-if="isCurrentUser">
<input
v-model="editableUsername"
type="text"
:placeholder="t('developerProfile.editUsername')"
class="h-8 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
data-testid="username-input"
/>
</template>
<template v-else>
<span class="text-lg font-semibold" data-testid="username-text">
{{ profile?.displayName ?? viewedUsername }}
</span>
</template>
<span
v-if="profile?.isVerified"
class="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2 py-0.5 text-xs text-blue-400"
>
<i class="icon-[lucide--badge-check] size-3" />
{{ t('developerProfile.verified') }}
</span>
</div>
<template v-if="isCurrentUser">
<textarea
v-model="editableBio"
:placeholder="t('developerProfile.editBio')"
rows="2"
class="resize-none rounded border border-border-default bg-secondary-background px-2 py-1 text-sm text-muted-foreground focus:outline-none"
data-testid="bio-input"
/>
</template>
<template v-else>
<p
v-if="profile?.bio"
class="m-0 text-sm text-muted-foreground"
data-testid="bio-text"
>
{{ profile.bio }}
</p>
</template>
</div>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-5 gap-3" data-testid="summary-stats">
<div
v-for="stat in summaryStats"
:key="stat.label"
class="flex flex-col items-center rounded-lg bg-secondary-background p-3"
>
<span class="text-lg font-semibold">{{ stat.value }}</span>
<span class="text-xs text-muted-foreground">{{ stat.label }}</span>
</div>
</div>
<!-- Download History Chart -->
<DownloadHistoryChart
v-if="downloadHistory.length > 0"
:entries="downloadHistory"
/>
<!-- Quick Actions (current user only) -->
<div
v-if="isCurrentUser"
class="rounded-lg border border-border-default p-4"
data-testid="quick-actions"
>
<h3 class="m-0 mb-3 text-sm font-semibold">
{{ t('developerProfile.quickActions') }}
</h3>
<div class="flex flex-wrap gap-2">
<Button
v-for="tpl in templates"
:key="tpl.id"
variant="destructive-textonly"
size="sm"
@click="handleUnpublish(tpl.id)"
>
{{ t('developerProfile.unpublish') }}: {{ tpl.title }}
</Button>
</div>
</div>
<!-- Reviews Section -->
<div data-testid="reviews-section">
<h3 class="m-0 mb-3 text-sm font-semibold">
{{ t('developerProfile.reviews') }}
</h3>
<div
v-if="reviews.length === 0"
class="py-4 text-center text-sm text-muted-foreground"
>
{{ t('developerProfile.noReviews') }}
</div>
<div
v-else
class="flex max-h-80 flex-col gap-2 overflow-y-auto scrollbar-custom"
>
<ReviewCard
v-for="review in reviews"
:key="review.id"
:review="review"
/>
</div>
</div>
<!-- Published Templates Section -->
<div data-testid="templates-section">
<h3 class="m-0 mb-3 text-sm font-semibold">
{{ t('developerProfile.publishedTemplates') }}
</h3>
<div
v-if="templates.length === 0"
class="py-4 text-center text-sm text-muted-foreground"
>
{{ t('developerProfile.noTemplates') }}
</div>
<div
v-else
class="flex max-h-96 flex-col gap-2 overflow-y-auto scrollbar-custom"
>
<TemplateListItem
v-for="tpl in templates"
:key="tpl.id"
:template="tpl"
:revenue="revenueByTemplateId[tpl.id]"
:show-revenue="showRevenueColumn"
:is-current-user="isCurrentUser"
@unpublish="handleUnpublish"
/>
</div>
</div>
</div>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { computed, provide, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import {
fetchDeveloperProfile,
fetchDeveloperReviews,
fetchDownloadHistory,
fetchPublishedTemplates,
fetchTemplateRevenue,
getCurrentUsername,
saveDeveloperProfile,
unpublishTemplate
} from '@/services/developerProfileService'
import type {
DeveloperProfile,
DownloadHistoryEntry,
MarketplaceTemplate,
TemplateReview,
TemplateRevenue
} from '@/types/templateMarketplace'
import { OnCloseKey } from '@/types/widgetTypes'
import DownloadHistoryChart from './DownloadHistoryChart.vue'
import ReviewCard from './ReviewCard.vue'
import TemplateListItem from './TemplateListItem.vue'
const { onClose, username } = defineProps<{
onClose: () => void
username?: string
}>()
const { t } = useI18n()
provide(OnCloseKey, onClose)
const viewedUsername = ref(username ?? getCurrentUsername())
const isCurrentUser = computed(
() => viewedUsername.value === getCurrentUsername()
)
const profile = ref<DeveloperProfile | null>(null)
const reviews = ref<TemplateReview[]>([])
const templates = ref<MarketplaceTemplate[]>([])
const revenue = ref<TemplateRevenue[]>([])
const downloadHistory = ref<DownloadHistoryEntry[]>([])
const isSaving = ref(false)
const editableUsername = ref('')
const editableBio = ref('')
const revenueByTemplateId = computed(() => {
const map: Record<string, TemplateRevenue> = {}
for (const entry of revenue.value) {
map[entry.templateId] = entry
}
return map
})
const showRevenueColumn = computed(
() => isCurrentUser.value && (profile.value?.monetizationEnabled ?? false)
)
const summaryStats = computed(() => [
{
label: t('developerProfile.dependencies'),
value: (profile.value?.dependencies ?? 371).toLocaleString()
},
{
label: t('developerProfile.totalDownloads'),
value: (profile.value?.totalDownloads ?? 0).toLocaleString()
},
{
label: t('developerProfile.totalFavorites'),
value: (profile.value?.totalFavorites ?? 0).toLocaleString()
},
{
label: t('developerProfile.averageRating'),
value: (profile.value?.averageRating ?? 0).toFixed(1)
},
{
label: t('developerProfile.templateCount'),
value: String(profile.value?.templateCount ?? 0)
}
])
watchDebounced(viewedUsername, () => void loadData(), { debounce: 500 })
async function loadData() {
const handle = viewedUsername.value
const [profileData, reviewsData, templatesData, historyData] =
await Promise.all([
fetchDeveloperProfile(handle),
fetchDeveloperReviews(handle),
fetchPublishedTemplates(handle),
fetchDownloadHistory(handle)
])
profile.value = profileData
reviews.value = reviewsData
templates.value = templatesData
downloadHistory.value = historyData
editableUsername.value = profileData.displayName
editableBio.value = profileData.bio ?? ''
if (isCurrentUser.value && profileData.monetizationEnabled) {
revenue.value = await fetchTemplateRevenue(handle)
} else {
revenue.value = []
}
}
async function saveProfile() {
isSaving.value = true
try {
profile.value = await saveDeveloperProfile({
...profile.value,
displayName: editableUsername.value,
bio: editableBio.value
})
} finally {
isSaving.value = false
}
}
async function handleUnpublish(templateId: string) {
await unpublishTemplate(templateId)
templates.value = templates.value.filter((t) => t.id !== templateId)
}
void loadData()
</script>

View File

@@ -0,0 +1,217 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DownloadHistoryEntry } from '@/types/templateMarketplace'
const { MockChart } = vi.hoisted(() => {
const mockDestroyFn = vi.fn()
class MockChartClass {
static register = vi.fn()
static instances: MockChartClass[] = []
type: string
data: unknown
destroy = mockDestroyFn
constructor(_canvas: unknown, config: { type: string; data: unknown }) {
this.type = config.type
this.data = config.data
MockChartClass.instances.push(this)
}
}
return { MockChart: MockChartClass, mockDestroyFn }
})
vi.mock('chart.js', () => ({
Chart: MockChart,
BarController: {},
BarElement: {},
CategoryScale: {},
Filler: {},
LineController: {},
LineElement: {},
LinearScale: {},
PointElement: {},
Tooltip: {}
}))
import DownloadHistoryChart from './DownloadHistoryChart.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
developerProfile: {
downloadHistory: 'Download History',
range: {
week: 'Week',
month: 'Month',
year: 'Year',
allTime: 'All Time'
}
}
}
}
})
function makeEntries(count: number): DownloadHistoryEntry[] {
const entries: DownloadHistoryEntry[] = []
const now = new Date()
for (let i = count - 1; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
date.setHours(0, 0, 0, 0)
entries.push({ date, downloads: 10 + i })
}
return entries
}
async function mountChart(entries?: DownloadHistoryEntry[]) {
const wrapper = mount(DownloadHistoryChart, {
props: { entries: entries ?? makeEntries(730) },
global: { plugins: [i18n] },
attachTo: document.createElement('div')
})
await nextTick()
await flushPromises()
await nextTick()
return wrapper
}
function lastInstance() {
return MockChart.instances.at(-1)
}
describe('DownloadHistoryChart', () => {
beforeEach(() => {
MockChart.instances = []
})
it('renders all four range buttons', async () => {
const wrapper = await mountChart()
const buttons = wrapper.find('[data-testid="range-buttons"]')
expect(buttons.exists()).toBe(true)
expect(wrapper.find('[data-testid="range-btn-week"]').text()).toBe('Week')
expect(wrapper.find('[data-testid="range-btn-month"]').text()).toBe('Month')
expect(wrapper.find('[data-testid="range-btn-year"]').text()).toBe('Year')
expect(wrapper.find('[data-testid="range-btn-allTime"]').text()).toBe(
'All Time'
)
})
it('defaults to week range with active styling', async () => {
const wrapper = await mountChart()
const weekBtn = wrapper.find('[data-testid="range-btn-week"]')
expect(weekBtn.classes()).toContain('font-semibold')
})
it('creates a bar chart for week range', async () => {
await mountChart()
expect(lastInstance()?.type).toBe('bar')
})
it('switches to month and creates a bar chart', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
await nextTick()
await flushPromises()
expect(lastInstance()?.type).toBe('bar')
})
it('switches to year and creates a line chart', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
await nextTick()
await flushPromises()
expect(lastInstance()?.type).toBe('line')
})
it('switches to allTime and creates a line chart', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
await nextTick()
await flushPromises()
expect(lastInstance()?.type).toBe('line')
})
it('destroys previous chart when switching ranges', async () => {
const wrapper = await mountChart()
const firstInstance = lastInstance()!
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
await nextTick()
await flushPromises()
expect(firstInstance.destroy).toHaveBeenCalled()
})
it('renders the heading text', async () => {
const wrapper = await mountChart()
expect(wrapper.text()).toContain('Download History')
})
it('passes 7 data points for week range', async () => {
await mountChart()
const chart = lastInstance()!
const labels = (chart.data as { labels: string[] }).labels
expect(labels).toHaveLength(7)
})
it('passes 31 data points for month range', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
await nextTick()
await flushPromises()
const chart = lastInstance()!
const labels = (chart.data as { labels: string[] }).labels
expect(labels).toHaveLength(31)
})
it('downsamples year range to weekly buckets', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
await nextTick()
await flushPromises()
const chart = lastInstance()!
const labels = (chart.data as { labels: string[] }).labels
// 365 days / 7 per bucket = 52 full + 1 partial = 53
expect(labels).toHaveLength(Math.ceil(365 / 7))
})
it('downsamples allTime range to monthly buckets', async () => {
const wrapper = await mountChart()
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
await nextTick()
await flushPromises()
const chart = lastInstance()!
const labels = (chart.data as { labels: string[] }).labels
// 730 days / 30 per bucket = 24 full + 1 partial = 25
expect(labels).toHaveLength(Math.ceil(730 / 30))
})
it('sums downloads within each aggregated bucket', async () => {
// 14 entries with downloads = 1 each, aggregated by 7 → 2 buckets of 7
const entries = makeEntries(14).map((e) => ({ ...e, downloads: 1 }))
const wrapper = mount(DownloadHistoryChart, {
props: { entries },
global: { plugins: [i18n] },
attachTo: document.createElement('div')
})
await nextTick()
await flushPromises()
await nextTick()
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
await nextTick()
await flushPromises()
const chart = lastInstance()!
const datasets = (chart.data as { datasets: { data: number[] }[] }).datasets
// 14 / 30 per bucket → 1 bucket with all 14 summed
expect(datasets[0].data).toEqual([14])
})
})

View File

@@ -0,0 +1,209 @@
<template>
<div
class="rounded-lg bg-secondary-background p-4"
data-testid="download-history-chart"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-sm font-semibold">
{{ t('developerProfile.downloadHistory') }}
</h3>
<div
class="flex gap-1 rounded-md bg-modal-panel-background p-0.5"
data-testid="range-buttons"
>
<button
v-for="range in RANGES"
:key="range"
:class="
cn(
'cursor-pointer rounded border-none px-2 py-1 text-xs transition-colors',
selectedRange === range
? 'bg-secondary-background font-semibold text-foreground'
: 'text-muted-foreground hover:text-foreground'
)
"
:data-testid="`range-btn-${range}`"
@click="selectedRange = range"
>
{{ t(`developerProfile.range.${range}`) }}
</button>
</div>
</div>
<div class="h-62.5">
<canvas ref="canvasRef" />
</div>
</div>
</template>
/** * Download history chart for the developer profile dashboard. * * Renders
daily download counts using Chart.js, with a toggle group in the * upper-right
corner that switches between four time ranges: * * - **Week** (7 bars) and
**Month** (31 bars) render as bar charts. * - **Year** (weekly buckets) and
**All Time** (monthly buckets) render as * filled area charts, with entries
aggregated into summed buckets to keep * the point count manageable. * * @prop
entries - Chronologically-ordered daily download history produced by * {@link
fetchDownloadHistory}. */
<script setup lang="ts">
import {
BarController,
BarElement,
CategoryScale,
Chart,
Filler,
LineController,
LineElement,
LinearScale,
PointElement,
Tooltip
} from 'chart.js'
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
DownloadHistoryEntry,
DownloadHistoryRange
} from '@/types/templateMarketplace'
import { cn } from '@/utils/tailwindUtil'
Chart.register(
BarController,
BarElement,
CategoryScale,
Filler,
LineController,
LineElement,
LinearScale,
PointElement,
Tooltip
)
const RANGES: DownloadHistoryRange[] = ['week', 'month', 'year', 'allTime']
const BAR_COLOR = '#185A8B'
const { entries } = defineProps<{
entries: DownloadHistoryEntry[]
}>()
const { t } = useI18n()
const selectedRange = ref<DownloadHistoryRange>('week')
const canvasRef = ref<HTMLCanvasElement | null>(null)
const chartInstance = shallowRef<Chart | null>(null)
/**
* Aggregates entries into buckets by summing downloads and using the last
* date in each bucket for the label.
*/
function aggregate(
source: DownloadHistoryEntry[],
bucketSize: number
): DownloadHistoryEntry[] {
const result: DownloadHistoryEntry[] = []
for (let i = 0; i < source.length; i += bucketSize) {
const bucket = source.slice(i, i + bucketSize)
const downloads = bucket.reduce((sum, e) => sum + e.downloads, 0)
result.push({ date: bucket[bucket.length - 1].date, downloads })
}
return result
}
/**
* Returns the tail slice of entries matching the selected range, downsampled
* for larger views, along with formatted date labels.
*/
function sliceEntries(range: DownloadHistoryRange): {
labels: string[]
data: number[]
} {
const count =
range === 'week' ? 7 : range === 'month' ? 31 : range === 'year' ? 365 : 0
const sliced = count > 0 ? entries.slice(-count) : entries
const sampled =
range === 'year'
? aggregate(sliced, 7)
: range === 'allTime'
? aggregate(sliced, 30)
: sliced
const labels = sampled.map((e) => {
const d = e.date
if (range === 'week')
return d.toLocaleDateString(undefined, { weekday: 'short' })
if (range === 'month') return String(d.getDate())
if (range === 'year')
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
})
return { labels, data: sampled.map((e) => e.downloads) }
}
/**
* Builds or replaces the Chart.js instance on the canvas whenever range
* or data changes.
*/
function renderChart() {
const canvas = canvasRef.value
if (!canvas) return
chartInstance.value?.destroy()
const range = selectedRange.value
const isBar = range === 'week' || range === 'month'
const { labels, data } = sliceEntries(range)
chartInstance.value = new Chart(canvas, {
type: isBar ? 'bar' : 'line',
data: {
labels,
datasets: [
{
data,
backgroundColor: isBar ? BAR_COLOR : `${BAR_COLOR}33`,
borderColor: BAR_COLOR,
borderWidth: isBar ? 0 : 2,
borderRadius: isBar ? { topLeft: 4, topRight: 4 } : undefined,
fill: !isBar,
tension: 0.3,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
transitions: { active: { animation: { duration: 0 } } },
plugins: { legend: { display: false }, tooltip: { mode: 'index' } },
scales: {
x: {
grid: { display: false },
ticks: {
color: '#9FA2BD',
maxRotation: 0,
autoSkip: true,
maxTicksLimit: isBar ? undefined : 12
}
},
y: {
beginAtZero: true,
grid: { color: '#9FA2BD22' },
ticks: { color: '#9FA2BD' }
}
}
}
})
}
watch([selectedRange, () => entries], renderChart, { flush: 'post' })
watch(canvasRef, (el) => {
if (el) renderChart()
})
onBeforeUnmount(() => {
chartInstance.value?.destroy()
})
</script>

View File

@@ -0,0 +1,63 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
import type { TemplateReview } from '@/types/templateMarketplace'
import ReviewCard from './ReviewCard.vue'
function makeReview(overrides?: Partial<TemplateReview>): TemplateReview {
return {
id: 'rev-1',
authorName: 'TestUser',
authorAvatarUrl: undefined,
rating: 4,
text: 'Great template!',
createdAt: new Date('2025-10-15'),
templateId: 'tpl-1',
...overrides
}
}
function mountCard(review: TemplateReview) {
return mount(ReviewCard, {
props: { review },
global: {
stubs: {
StarRating: {
template: '<span data-testid="star-rating" :data-rating="rating" />',
props: ['rating', 'size']
}
}
}
})
}
describe('ReviewCard', () => {
it('renders the author name', () => {
const wrapper = mountCard(makeReview({ authorName: 'PixelWizard' }))
expect(wrapper.text()).toContain('PixelWizard')
})
it('renders the review text', () => {
const wrapper = mountCard(makeReview({ text: 'Awesome workflow!' }))
expect(wrapper.text()).toContain('Awesome workflow!')
})
it('passes the rating to StarRating', () => {
const wrapper = mountCard(makeReview({ rating: 3.5 }))
const starRating = wrapper.find('[data-testid="star-rating"]')
expect(starRating.exists()).toBe(true)
expect(starRating.attributes('data-rating')).toBe('3.5')
})
it('renders a formatted date', () => {
const wrapper = mountCard(makeReview({ createdAt: new Date('2025-10-15') }))
expect(wrapper.text()).toContain('2025')
})
})

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex flex-col gap-2 rounded-lg bg-secondary-background p-4">
<div class="flex items-center gap-2">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
>
<i class="icon-[lucide--user] size-4 text-muted-foreground" />
</div>
<span class="text-sm font-medium">{{ review.authorName }}</span>
<StarRating :rating="review.rating" size="sm" />
<span class="ml-auto text-xs text-muted-foreground">
{{ formattedDate }}
</span>
</div>
<p class="m-0 text-sm text-muted-foreground">{{ review.text }}</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { TemplateReview } from '@/types/templateMarketplace'
import StarRating from './StarRating.vue'
const { review } = defineProps<{
/** The review to display. */
review: TemplateReview
}>()
const formattedDate = computed(() =>
review.createdAt.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
)
</script>

View File

@@ -0,0 +1,72 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
import StarRating from './StarRating.vue'
function mountRating(rating: number, size?: 'sm' | 'md') {
return mount(StarRating, {
props: { rating, size }
})
}
describe('StarRating', () => {
it('renders five star containers', () => {
const wrapper = mountRating(3)
const starContainers = wrapper.findAll('[role="img"] > div')
expect(starContainers).toHaveLength(5)
})
it('fills all stars for a rating of 5', () => {
const wrapper = mountRating(5)
const fills = wrapper.findAll('[role="img"] > div > div')
expect(fills).toHaveLength(5)
for (const fill of fills) {
expect(fill.attributes('style')).toContain('width: 100%')
}
})
it('fills no stars for a rating of 0', () => {
const wrapper = mountRating(0)
const fills = wrapper.findAll('[role="img"] > div > div')
expect(fills).toHaveLength(0)
})
it('renders correct fills for a half-star rating of 3.5', () => {
const wrapper = mountRating(3.5)
const fills = wrapper.findAll('[role="img"] > div > div')
expect(fills).toHaveLength(4)
expect(fills[0].attributes('style')).toContain('width: 100%')
expect(fills[1].attributes('style')).toContain('width: 100%')
expect(fills[2].attributes('style')).toContain('width: 100%')
expect(fills[3].attributes('style')).toContain('width: 50%')
})
it('renders correct fills for a half-star rating of 2.5', () => {
const wrapper = mountRating(2.5)
const fills = wrapper.findAll('[role="img"] > div > div')
expect(fills).toHaveLength(3)
expect(fills[0].attributes('style')).toContain('width: 100%')
expect(fills[1].attributes('style')).toContain('width: 100%')
expect(fills[2].attributes('style')).toContain('width: 50%')
})
it('uses smaller size class when size is sm', () => {
const wrapper = mountRating(3, 'sm')
const html = wrapper.html()
expect(html).toContain('size-3.5')
})
it('uses default size class when size is md', () => {
const wrapper = mountRating(3, 'md')
const html = wrapper.html()
expect(html).toContain('size-4')
})
})

View File

@@ -0,0 +1,56 @@
<template>
<div
class="inline-flex items-center gap-0.5"
role="img"
:aria-label="ariaLabel"
>
<div v-for="i in 5" :key="i" class="relative" :class="starSizeClass">
<i
:class="
cn('icon-[lucide--star]', starSizeClass, 'text-muted-foreground')
"
/>
<div
v-if="fillWidth(i) > 0"
class="absolute inset-0 overflow-hidden"
:style="{ width: `${fillWidth(i)}%` }"
>
<i
:class="cn('icon-[lucide--star]', starSizeClass, 'text-amber-400')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
const { rating, size = 'md' } = defineProps<{
/** Star rating value from 0 to 5, supporting 0.5 increments. */
rating: number
/** Visual size variant. */
size?: 'sm' | 'md'
}>()
const { t } = useI18n()
const starSizeClass = computed(() => (size === 'sm' ? 'size-3.5' : 'size-4'))
const ariaLabel = computed(
() => t('developerProfile.rating') + ': ' + String(rating) + '/5'
)
/**
* Returns the fill percentage (0, 50, or 100) for the star at position `i`.
* @param i - 1-indexed star position.
*/
function fillWidth(i: number): number {
if (rating >= i) return 100
if (rating >= i - 0.5) return 50
return 0
}
</script>

View File

@@ -0,0 +1,154 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
import type {
MarketplaceTemplate,
TemplateRevenue
} from '@/types/templateMarketplace'
import TemplateListItem from './TemplateListItem.vue'
function makeTemplate(
overrides?: Partial<MarketplaceTemplate>
): MarketplaceTemplate {
return {
id: 'tpl-1',
title: 'Test Template',
description: 'Full description',
shortDescription: 'Short desc',
author: {
id: 'usr-1',
name: 'Author',
isVerified: true,
profileUrl: '/author'
},
categories: [],
tags: [],
difficulty: 'beginner',
requiredModels: [],
requiredNodes: [],
requiresCustomNodes: [],
vramRequirement: 0,
thumbnail: '',
gallery: [],
workflowPreview: '',
license: 'mit',
version: '1.0.0',
status: 'approved',
updatedAt: new Date(),
stats: {
downloads: 1000,
favorites: 50,
rating: 4.5,
reviewCount: 10,
weeklyTrend: 2
},
...overrides
}
}
const stubRevenue: TemplateRevenue = {
templateId: 'tpl-1',
totalRevenue: 10_000,
monthlyRevenue: 1_500,
currency: 'USD'
}
interface MountOptions {
template?: MarketplaceTemplate
revenue?: TemplateRevenue
showRevenue?: boolean
isCurrentUser?: boolean
}
function mountItem(options: MountOptions = {}) {
return mount(TemplateListItem, {
props: {
template: options.template ?? makeTemplate(),
revenue: options.revenue,
showRevenue: options.showRevenue ?? false,
isCurrentUser: options.isCurrentUser ?? false
},
global: {
stubs: {
StarRating: {
template: '<span data-testid="star-rating" />',
props: ['rating', 'size']
},
Button: {
template: '<button data-testid="unpublish-button"><slot /></button>',
props: ['variant', 'size']
}
}
}
})
}
describe('TemplateListItem', () => {
it('renders the template title and description', () => {
const wrapper = mountItem({
template: makeTemplate({
title: 'My Workflow',
shortDescription: 'A cool workflow'
})
})
expect(wrapper.text()).toContain('My Workflow')
expect(wrapper.text()).toContain('A cool workflow')
})
it('renders download and favorite stats', () => {
const wrapper = mountItem({
template: makeTemplate({
stats: {
downloads: 5_000,
favorites: 200,
rating: 4,
reviewCount: 15,
weeklyTrend: 1
}
})
})
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('200')
})
it('hides revenue column when showRevenue is false', () => {
const wrapper = mountItem({
revenue: stubRevenue,
showRevenue: false
})
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(false)
})
it('shows revenue column when showRevenue is true', () => {
const wrapper = mountItem({
revenue: stubRevenue,
showRevenue: true
})
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(true)
})
it('hides unpublish button when isCurrentUser is false', () => {
const wrapper = mountItem({ isCurrentUser: false })
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(
false
)
})
it('shows unpublish button when isCurrentUser is true', () => {
const wrapper = mountItem({ isCurrentUser: true })
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(true)
})
it('emits unpublish event with template ID when button is clicked', async () => {
const wrapper = mountItem({ isCurrentUser: true })
await wrapper.find('[data-testid="unpublish-button"]').trigger('click')
expect(wrapper.emitted('unpublish')).toEqual([['tpl-1']])
})
})

View File

@@ -0,0 +1,114 @@
<template>
<div
class="flex items-center gap-4 rounded-lg bg-secondary-background p-3"
data-testid="template-list-item"
>
<div
class="size-12 shrink-0 overflow-hidden rounded bg-modal-panel-background"
>
<img
v-if="template.thumbnail"
:src="template.thumbnail"
:alt="template.title"
class="size-full object-cover"
/>
<div v-else class="flex size-full items-center justify-center">
<i class="icon-[lucide--image] size-5 text-muted-foreground" />
</div>
</div>
<div class="min-w-0 flex-1">
<h4 class="m-0 truncate text-sm font-medium">{{ template.title }}</h4>
<p class="m-0 truncate text-xs text-muted-foreground">
{{ template.shortDescription }}
</p>
</div>
<div class="flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
<span
class="flex items-center gap-1"
:title="t('developerProfile.downloads')"
>
<i class="icon-[lucide--download] size-3.5" />
{{ template.stats.downloads.toLocaleString() }}
</span>
<span
class="flex items-center gap-1"
:title="t('developerProfile.favorites')"
>
<i class="icon-[lucide--heart] size-3.5" />
{{ template.stats.favorites.toLocaleString() }}
</span>
<StarRating :rating="template.stats.rating" size="sm" />
</div>
<div
v-if="showRevenue && revenue"
class="shrink-0 text-right text-xs"
data-testid="revenue-column"
>
<div class="font-medium">{{ formatCurrency(revenue.totalRevenue) }}</div>
<div class="text-muted-foreground">
{{ formatCurrency(revenue.monthlyRevenue) }}/{{
t('developerProfile.monthlyRevenue').toLowerCase()
}}
</div>
</div>
<Button
v-if="isCurrentUser"
variant="destructive-textonly"
size="sm"
data-testid="unpublish-button"
@click="emit('unpublish', template.id)"
>
{{ t('developerProfile.unpublish') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
MarketplaceTemplate,
TemplateRevenue
} from '@/types/templateMarketplace'
import StarRating from './StarRating.vue'
const {
template,
revenue,
showRevenue = false,
isCurrentUser = false
} = defineProps<{
/** The template to display. */
template: MarketplaceTemplate
/** Revenue data for this template, shown when showRevenue is true. */
revenue?: TemplateRevenue
/** Whether to display the revenue column. */
showRevenue?: boolean
/** Whether the profile being viewed belongs to the current user. */
isCurrentUser?: boolean
}>()
const emit = defineEmits<{
/** Emitted when the unpublish button is clicked. */
unpublish: [templateId: string]
}>()
const { t } = useI18n()
/**
* Formats a value in cents as a currency string.
* @param cents - Amount in cents.
*/
function formatCurrency(cents: number): string {
return (cents / 100).toLocaleString(undefined, {
style: 'currency',
currency: 'USD'
})
}
</script>

View File

@@ -0,0 +1,31 @@
<!--
Floating indicator that displays the estimated VRAM requirement
for the currently loaded workflow graph.
-->
<template>
<div
v-if="vramEstimate > 0"
class="pointer-events-auto absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/40 px-2.5 py-1.5 text-xs font-medium text-white/90 backdrop-blur-sm"
:title="t('templateWorkflows.vramEstimateTooltip')"
>
<i class="icon-[lucide--cpu] h-3.5 w-3.5" />
{{ formatSize(vramEstimate) }}
</div>
</template>
<script setup lang="ts">
import { formatSize } from '@/utils/formatUtil'
import { ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
import { app } from '@/scripts/app'
const { t } = useI18n()
const vramEstimate = ref(0)
watchEffect(() => {
vramEstimate.value = estimateWorkflowVram(app.rootGraph)
})
</script>

View File

@@ -0,0 +1,109 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import type { CachedAsset } from '@/types/templateMarketplace'
import TemplateAssetUploadZone from './TemplateAssetUploadZone.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
previewGeneration: {
uploadPrompt: 'Click to upload',
removeFile: 'Remove'
}
}
}
}
}
})
function makeAsset(name: string): CachedAsset {
return {
file: new File(['data'], name, { type: 'image/png' }),
objectUrl: `blob:http://localhost/${name}`,
originalName: name
}
}
function mountZone(props: Record<string, unknown> = {}) {
return mount(TemplateAssetUploadZone, {
props,
global: { plugins: [i18n] }
})
}
describe('TemplateAssetUploadZone', () => {
it('shows the upload prompt when no asset is provided', () => {
const wrapper = mountZone()
expect(wrapper.text()).toContain('Click to upload')
expect(wrapper.find('img').exists()).toBe(false)
})
it('shows an image preview when an asset is provided', () => {
const asset = makeAsset('photo.png')
const wrapper = mountZone({ asset })
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe(asset.objectUrl)
expect(wrapper.text()).toContain('photo.png')
})
it('shows a video element when previewType is video', () => {
const asset = makeAsset('demo.mp4')
const wrapper = mountZone({ asset, previewType: 'video' })
expect(wrapper.find('video').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(false)
})
it('emits upload with the selected file', async () => {
const wrapper = mountZone()
const input = wrapper.find('input[type="file"]')
const file = new File(['bytes'], 'test.png', { type: 'image/png' })
Object.defineProperty(input.element, 'files', { value: [file] })
await input.trigger('change')
expect(wrapper.emitted('upload')).toHaveLength(1)
expect(wrapper.emitted('upload')![0]).toEqual([file])
})
it('emits remove when the remove button is clicked', async () => {
const wrapper = mountZone({ asset: makeAsset('photo.png') })
const removeBtn = wrapper.find('button[aria-label="Remove"]')
await removeBtn.trigger('click')
expect(wrapper.emitted('remove')).toHaveLength(1)
})
it('applies the provided sizeClass to the upload zone', () => {
const wrapper = mountZone({ sizeClass: 'h-40 w-64' })
const zone = wrapper.find('[role="button"]')
expect(zone.classes()).toContain('h-40')
expect(zone.classes()).toContain('w-64')
})
it('uses image/* accept filter by default', () => {
const wrapper = mountZone()
const input = wrapper.find('input[type="file"]')
expect(input.attributes('accept')).toBe('image/*')
})
it('applies a custom accept filter', () => {
const wrapper = mountZone({ accept: 'video/*' })
const input = wrapper.find('input[type="file"]')
expect(input.attributes('accept')).toBe('video/*')
})
})

View File

@@ -0,0 +1,109 @@
<!--
Reusable upload zone for a single file asset. Shows a dashed click-to-upload
area when empty, and a preview with filename overlay when populated.
-->
<template>
<div>
<div
v-if="!asset"
:class="
cn(
'flex cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-default hover:border-muted-foreground',
sizeClass
)
"
role="button"
:tabindex="0"
:aria-label="t('templatePublishing.steps.previewGeneration.uploadPrompt')"
@click="fileInput?.click()"
@keydown.enter="fileInput?.click()"
>
<div class="flex flex-col items-center gap-1 text-muted-foreground">
<i class="icon-[lucide--upload] h-5 w-5" />
<span class="text-xs">
{{ t('templatePublishing.steps.previewGeneration.uploadPrompt') }}
</span>
</div>
</div>
<div
v-else
:class="cn('group relative overflow-hidden rounded-lg', sizeClass)"
>
<img
v-if="previewType === 'image'"
:src="asset.objectUrl"
:alt="asset.originalName"
class="h-full w-full object-cover"
/>
<video
v-else
:src="asset.objectUrl"
controls
class="h-full w-full object-cover"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-2 py-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<span class="truncate text-xs text-white">
{{ asset.originalName }}
</span>
<button
type="button"
class="shrink-0 text-white hover:text-danger"
:aria-label="
t('templatePublishing.steps.previewGeneration.removeFile')
"
@click="emit('remove')"
>
<i class="icon-[lucide--x] h-4 w-4" />
</button>
</div>
</div>
<input
ref="fileInput"
type="file"
:accept="accept"
class="hidden"
@change="onFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CachedAsset } from '@/types/templateMarketplace'
import { cn } from '@/utils/tailwindUtil'
const {
asset = null,
accept = 'image/*',
previewType = 'image',
sizeClass = 'h-32 w-48'
} = defineProps<{
asset?: CachedAsset | null
accept?: string
previewType?: 'image' | 'video'
sizeClass?: string
}>()
const emit = defineEmits<{
upload: [file: File]
remove: []
}>()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
emit('upload', file)
input.value = ''
}
}
</script>

View File

@@ -0,0 +1,182 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
vi.mock(
'@/platform/workflow/templates/composables/useTemplatePublishStorage',
() => ({
loadTemplateUnderway: vi.fn(() => null),
saveTemplateUnderway: vi.fn()
})
)
import TemplatePublishingDialog from './TemplatePublishingDialog.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
dialogTitle: 'Template Publishing',
next: 'Next',
previous: 'Previous',
saveDraft: 'Save Draft',
stepProgress: 'Step {current} of {total}',
steps: {
landing: {
title: 'Getting Started',
description: 'Overview of the publishing process'
},
metadata: {
title: 'Metadata',
description: 'Title, description, and author info'
},
description: {
title: 'Description',
description: 'Write a detailed description of your template'
},
previewGeneration: {
title: 'Preview',
description: 'Generate preview images and videos'
},
categoryAndTagging: {
title: 'Categories & Tags',
description: 'Categorize and tag your template'
},
preview: {
title: 'Preview',
description: 'Review your template before submitting'
},
submissionForReview: {
title: 'Submit',
description: 'Submit your template for review'
},
complete: {
title: 'Complete',
description: 'Your template has been submitted'
}
}
}
}
}
})
function mountDialog(props?: { initialPage?: string }) {
return mount(TemplatePublishingDialog, {
props: {
onClose: vi.fn(),
...props
},
global: {
plugins: [i18n],
stubs: {
BaseModalLayout: {
template: `
<div data-testid="modal">
<div data-testid="left-panel"><slot name="leftPanel" /></div>
<div data-testid="header"><slot name="header" /></div>
<div data-testid="header-right"><slot name="header-right-area" /></div>
<div data-testid="content"><slot name="content" /></div>
</div>
`
},
TemplatePublishingStepperNav: {
template: '<div data-testid="stepper-nav" />',
props: ['currentStep', 'stepDefinitions']
},
StepTemplatePublishingLanding: {
template: '<div data-testid="step-landing" />'
},
StepTemplatePublishingMetadata: {
template: '<div data-testid="step-metadata" />'
},
StepTemplatePublishingDescription: {
template: '<div data-testid="step-description" />'
},
StepTemplatePublishingPreviewGeneration: {
template: '<div data-testid="step-preview-generation" />'
},
StepTemplatePublishingCategoryAndTagging: {
template: '<div data-testid="step-category" />'
},
StepTemplatePublishingPreview: {
template: '<div data-testid="step-preview" />'
},
StepTemplatePublishingSubmissionForReview: {
template: '<div data-testid="step-submission" />'
},
StepTemplatePublishingComplete: {
template: '<div data-testid="step-complete" />'
}
}
}
})
}
describe('TemplatePublishingDialog', () => {
it('renders the dialog with the first step by default', () => {
const wrapper = mountDialog()
expect(wrapper.find('[data-testid="modal"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
})
it('renders the stepper nav in the left panel', () => {
const wrapper = mountDialog()
const leftPanel = wrapper.find('[data-testid="left-panel"]')
expect(leftPanel.find('[data-testid="stepper-nav"]').exists()).toBe(true)
})
it('maps initialPage to the correct starting step', () => {
const wrapper = mountDialog({ initialPage: 'metadata' })
expect(wrapper.find('[data-testid="step-metadata"]').exists()).toBe(true)
})
it('defaults to step 1 for unknown initialPage', () => {
const wrapper = mountDialog({ initialPage: 'nonexistent' })
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
})
it('shows Previous button when not on first step', () => {
const wrapper = mountDialog({ initialPage: 'metadata' })
const headerRight = wrapper.find('[data-testid="header-right"]')
const buttons = headerRight.findAll('button')
const buttonTexts = buttons.map((b) => b.text())
expect(buttonTexts.some((text) => text.includes('Previous'))).toBe(true)
})
it('disables Previous button on first step', () => {
const wrapper = mountDialog()
const headerRight = wrapper.find('[data-testid="header-right"]')
const prevButton = headerRight
.findAll('button')
.find((b) => b.text().includes('Previous'))
expect(prevButton?.attributes('disabled')).toBeDefined()
})
it('disables Next button on last step', () => {
const wrapper = mountDialog({
initialPage: 'complete'
})
const headerRight = wrapper.find('[data-testid="header-right"]')
const nextButton = headerRight
.findAll('button')
.find((b) => b.text().includes('Next'))
expect(nextButton?.attributes('disabled')).toBeDefined()
})
it('disables Next button on submit step', () => {
const wrapper = mountDialog({
initialPage: 'submissionForReview'
})
const headerRight = wrapper.find('[data-testid="header-right"]')
const nextButton = headerRight
.findAll('button')
.find((b) => b.text().includes('Next'))
expect(nextButton?.attributes('disabled')).toBeDefined()
})
})

View File

@@ -0,0 +1,152 @@
<template>
<BaseModalLayout
:content-title="t('templatePublishing.dialogTitle')"
size="md"
>
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--upload]" />
<h2 class="text-neutral text-base">
{{ t('templatePublishing.dialogTitle') }}
</h2>
</template>
<template #leftPanel>
<TemplatePublishingStepperNav
:current-step="currentStep"
:step-definitions="stepDefinitions"
@update:current-step="goToStep"
/>
</template>
<template #header>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{{
t('templatePublishing.stepProgress', {
current: currentStep,
total: totalSteps
})
}}
</span>
</div>
</template>
<template #header-right-area>
<div class="mr-6 flex gap-2">
<Button
:disabled="isFirstStep"
variant="secondary"
size="lg"
@click="prevStep"
>
<i class="icon-[lucide--arrow-left]" />
{{ t('templatePublishing.previous') }}
</Button>
<Button
:disabled="
currentStep >= totalSteps - 1 ||
currentStep === STEP_PAGE_MAP.preview
"
size="lg"
@click="nextStep"
>
{{ t('templatePublishing.next') }}
<i class="icon-[lucide--arrow-right]" />
</Button>
</div>
</template>
<template #content>
<component :is="activeStepComponent" />
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import { OnCloseKey } from '@/types/widgetTypes'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import { useTemplatePublishingStepper } from '@/composables/useTemplatePublishingStepper'
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
import StepTemplatePublishingCategoryAndTagging from './steps/StepTemplatePublishingCategoryAndTagging.vue'
import StepTemplatePublishingComplete from './steps/StepTemplatePublishingComplete.vue'
import StepTemplatePublishingDescription from './steps/StepTemplatePublishingDescription.vue'
import StepTemplatePublishingLanding from './steps/StepTemplatePublishingLanding.vue'
import StepTemplatePublishingMetadata from './steps/StepTemplatePublishingMetadata.vue'
import StepTemplatePublishingPreview from './steps/StepTemplatePublishingPreview.vue'
import StepTemplatePublishingPreviewGeneration from './steps/StepTemplatePublishingPreviewGeneration.vue'
import StepTemplatePublishingSubmissionForReview from './steps/StepTemplatePublishingSubmissionForReview.vue'
import { PublishingStepperKey } from './types'
const { onClose, initialPage } = defineProps<{
onClose: () => void
initialPage?: string
}>()
const { t } = useI18n()
provide(OnCloseKey, onClose)
const STEP_PAGE_MAP: Record<string, number> = {
publishingLanding: 1,
metadata: 2,
description: 3,
previewGeneration: 4,
categoryAndTagging: 5,
preview: 6,
submissionForReview: 7,
complete: 8
}
const initialStep = initialPage ? (STEP_PAGE_MAP[initialPage] ?? 1) : 1
const {
currentStep,
totalSteps,
template,
stepDefinitions,
isFirstStep,
isLastStep,
canProceed,
nextStep,
prevStep,
goToStep,
saveDraft,
setStepValid
} = useTemplatePublishingStepper({ initialStep })
const STEP_COMPONENTS: Component[] = [
StepTemplatePublishingLanding,
StepTemplatePublishingMetadata,
StepTemplatePublishingDescription,
StepTemplatePublishingPreviewGeneration,
StepTemplatePublishingCategoryAndTagging,
StepTemplatePublishingPreview,
StepTemplatePublishingSubmissionForReview,
StepTemplatePublishingComplete
]
const activeStepComponent = computed(
() => STEP_COMPONENTS[currentStep.value - 1]
)
provide(PublishingStepperKey, {
currentStep,
totalSteps,
isFirstStep,
isLastStep,
canProceed,
template,
nextStep,
prevStep,
goToStep,
saveDraft,
setStepValid
})
</script>

View File

@@ -0,0 +1,103 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import type { PublishingStepDefinition } from './types'
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
const STEP_DEFINITIONS: PublishingStepDefinition[] = [
{
number: 1,
titleKey: 'steps.landing.title',
descriptionKey: 'steps.landing.description'
},
{
number: 2,
titleKey: 'steps.metadata.title',
descriptionKey: 'steps.metadata.description'
},
{
number: 3,
titleKey: 'steps.preview.title',
descriptionKey: 'steps.preview.description'
}
]
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
steps: {
landing: { title: 'Getting Started', description: '' },
metadata: { title: 'Metadata', description: '' },
preview: { title: 'Preview', description: '' }
}
}
}
})
function mountNav(props?: { currentStep?: number }) {
return mount(TemplatePublishingStepperNav, {
props: {
currentStep: props?.currentStep ?? 1,
stepDefinitions: STEP_DEFINITIONS
},
global: { plugins: [i18n] }
})
}
describe('TemplatePublishingStepperNav', () => {
it('renders a button for each step definition', () => {
const wrapper = mountNav()
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(STEP_DEFINITIONS.length)
})
it('displays translated step titles', () => {
const wrapper = mountNav()
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toContain('Getting Started')
expect(buttons[1].text()).toContain('Metadata')
expect(buttons[2].text()).toContain('Preview')
})
it('marks the current step button as aria-selected', () => {
const wrapper = mountNav({ currentStep: 2 })
const buttons = wrapper.findAll('button')
expect(buttons[0].attributes('aria-selected')).toBe('false')
expect(buttons[1].attributes('aria-selected')).toBe('true')
expect(buttons[2].attributes('aria-selected')).toBe('false')
})
it('shows a check icon for completed steps', () => {
const wrapper = mountNav({ currentStep: 3 })
const buttons = wrapper.findAll('button')
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
expect(buttons[1].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
expect(buttons[2].find('i.icon-\\[lucide--check\\]').exists()).toBe(false)
})
it('shows step numbers for current and future steps', () => {
const wrapper = mountNav({ currentStep: 2 })
const buttons = wrapper.findAll('button')
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
expect(buttons[1].text()).toContain('2')
expect(buttons[2].text()).toContain('3')
})
it('emits update:currentStep when a step button is clicked', async () => {
const wrapper = mountNav({ currentStep: 1 })
await wrapper.findAll('button')[1].trigger('click')
expect(wrapper.emitted('update:currentStep')).toEqual([[2]])
})
it('renders separators between steps', () => {
const wrapper = mountNav()
const separators = wrapper.findAll('div.bg-border-default')
expect(separators).toHaveLength(STEP_DEFINITIONS.length - 1)
})
})

View File

@@ -0,0 +1,83 @@
<template>
<nav
class="flex flex-col gap-1 px-4 py-2"
role="tablist"
aria-orientation="vertical"
>
<template v-for="(step, index) in stepDefinitions" :key="step.number">
<button
role="tab"
:aria-selected="step.number === currentStep"
:class="
cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm',
'focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2',
step.number === currentStep &&
step.number === stepDefinitions.length &&
'bg-blue-900 font-medium text-neutral',
step.number === currentStep &&
step.number < stepDefinitions.length &&
'font-medium text-neutral',
step.number < currentStep && 'bg-green-900 text-muted-foreground',
step.number > currentStep && 'text-muted-foreground opacity-50'
)
"
:disabled="step.number === stepDefinitions.length"
@click="emit('update:currentStep', step.number)"
>
<span
:class="
cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs',
step.number === currentStep &&
'bg-comfy-accent text-comfy-accent-foreground',
step.number < currentStep && 'bg-comfy-accent/20 text-neutral',
step.number > currentStep &&
'bg-secondary-background text-muted-foreground'
)
"
>
<i
v-if="step.number < currentStep"
class="icon-[lucide--check] h-3.5 w-3.5"
/>
<span v-else>{{ step.number }}</span>
</span>
<span class="leading-tight">
{{ t(step.titleKey)
}}<template
v-if="
step.number === currentStep &&
step.number === stepDefinitions.length
"
>
&#127881;</template
>
</span>
</button>
<div
v-if="index < stepDefinitions.length - 1"
class="bg-border-default ml-5 h-4 w-px"
/>
</template>
</nav>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { PublishingStepDefinition } from './types'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
currentStep: number
stepDefinitions: PublishingStepDefinition[]
}>()
const emit = defineEmits<{
'update:currentStep': [step: number]
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,189 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingCategoryAndTagging from './StepTemplatePublishingCategoryAndTagging.vue'
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
const typedActual = actual as {
watchDebounced: (...args: unknown[]) => unknown
}
return typedActual.watchDebounced(source, cb, {
...(opts as object),
debounce: 0
})
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
metadata: {
categoryLabel: 'Categories',
tagsLabel: 'Tags',
tagsPlaceholder: 'Type to search tags…',
category: {
imageGeneration: 'Image Generation',
videoGeneration: 'Video Generation',
audio: 'Audio',
text: 'Text',
threeD: '3D',
upscaling: 'Upscaling',
inpainting: 'Inpainting',
controlNet: 'ControlNet',
styleTransfer: 'Style Transfer',
other: 'Other'
}
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(5)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingCategoryAndTagging, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingCategoryAndTagging', () => {
it('renders category and tag labels', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Categories')
expect(wrapper.text()).toContain('Tags')
})
it('renders all category checkboxes', () => {
const { wrapper } = mountStep()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(10)
expect(wrapper.text()).toContain('Image Generation')
expect(wrapper.text()).toContain('ControlNet')
})
it('toggles category when checkbox is clicked', async () => {
const ctx = createContext({ categories: [] })
const { wrapper } = mountStep(ctx)
const checkbox = wrapper.find('#tpl-category-audio')
await checkbox.setValue(true)
expect(ctx.template.value.categories).toContain('audio')
await checkbox.setValue(false)
expect(ctx.template.value.categories).not.toContain('audio')
})
it('preserves existing categories when toggling', async () => {
const ctx = createContext({ categories: ['text', '3d'] })
const { wrapper } = mountStep(ctx)
const audioCheckbox = wrapper.find('#tpl-category-audio')
await audioCheckbox.setValue(true)
expect(ctx.template.value.categories).toContain('text')
expect(ctx.template.value.categories).toContain('3d')
expect(ctx.template.value.categories).toContain('audio')
})
it('adds a tag when pressing enter in the tags input', async () => {
const ctx = createContext({ tags: [] })
const { wrapper } = mountStep(ctx)
const tagInput = wrapper.find('input[type="text"]')
await tagInput.setValue('my-tag')
await tagInput.trigger('keydown.enter')
expect(ctx.template.value.tags).toContain('my-tag')
})
it('does not add duplicate tags', async () => {
const ctx = createContext({ tags: ['existing'] })
const { wrapper } = mountStep(ctx)
const tagInput = wrapper.find('input[type="text"]')
await tagInput.setValue('existing')
await tagInput.trigger('keydown.enter')
expect(ctx.template.value.tags).toEqual(['existing'])
})
it('removes a tag when the remove button is clicked', async () => {
const ctx = createContext({ tags: ['alpha', 'beta'] })
const { wrapper } = mountStep(ctx)
const removeButtons = wrapper.findAll('button[aria-label^="Remove tag"]')
await removeButtons[0].trigger('click')
expect(ctx.template.value.tags).toEqual(['beta'])
})
it('shows filtered suggestions when typing in tags input', async () => {
const ctx = createContext({ tags: [] })
const { wrapper } = mountStep(ctx)
const tagInput = wrapper.find('input[type="text"]')
await tagInput.setValue('flux')
await tagInput.trigger('focus')
const suggestions = wrapper.findAll('li')
expect(suggestions.length).toBeGreaterThan(0)
expect(suggestions[0].text()).toBe('flux')
})
it('adds a suggestion tag when clicking it', async () => {
const ctx = createContext({ tags: [] })
const { wrapper } = mountStep(ctx)
const tagInput = wrapper.find('input[type="text"]')
await tagInput.setValue('flux')
await tagInput.trigger('focus')
const suggestion = wrapper.find('li')
await suggestion.trigger('mousedown')
expect(ctx.template.value.tags).toContain('flux')
})
})

View File

@@ -0,0 +1,179 @@
<template>
<div class="flex flex-col gap-6 p-6">
<div class="flex flex-row items-center gap-2">
<div class="form-label flex w-28 shrink-0 items-center">
<span id="tpl-category-label" class="text-muted">
{{ t('templatePublishing.steps.metadata.categoryLabel') }}
</span>
</div>
<div
class="flex flex-wrap gap-2"
role="group"
aria-labelledby="tpl-category-label"
>
<label
v-for="cat in CATEGORIES"
:key="cat.value"
:for="`tpl-category-${cat.value}`"
class="flex cursor-pointer items-center gap-1.5 text-sm"
>
<input
:id="`tpl-category-${cat.value}`"
type="checkbox"
:checked="ctx.template.value.categories?.includes(cat.value)"
@change="toggleCategory(cat.value)"
/>
{{ t(`templatePublishing.steps.metadata.category.${cat.key}`) }}
</label>
</div>
</div>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex w-28 shrink-0 items-center">
<span id="tpl-tags-label" class="text-muted">
{{ t('templatePublishing.steps.metadata.tagsLabel') }}
</span>
</div>
<div class="flex flex-col gap-1">
<div
v-if="(ctx.template.value.tags ?? []).length > 0"
class="flex max-h-20 flex-wrap gap-1 overflow-y-auto scrollbar-custom"
>
<span
v-for="tag in ctx.template.value.tags ?? []"
:key="tag"
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
>
{{ tag }}
<button
type="button"
class="hover:text-danger"
:aria-label="`Remove tag ${tag}`"
@click="removeTag(tag)"
>
<i class="icon-[lucide--x] h-3 w-3" />
</button>
</span>
</div>
<div class="relative">
<input
v-model="tagQuery"
type="text"
class="h-8 w-44 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
:placeholder="
t('templatePublishing.steps.metadata.tagsPlaceholder')
"
aria-labelledby="tpl-tags-label"
@focus="showSuggestions = true"
@keydown.enter.prevent="addTag(tagQuery)"
/>
<ul
v-if="showSuggestions && filteredSuggestions.length > 0"
class="absolute z-10 mt-1 max-h-40 w-44 overflow-auto rounded border border-border-default bg-secondary-background shadow-md"
>
<li
v-for="suggestion in filteredSuggestions"
:key="suggestion"
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
@mousedown.prevent="addTag(suggestion)"
>
{{ suggestion }}
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { PublishingStepperKey } from '../types'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const CATEGORIES = [
{ key: 'threeD', value: '3d' },
{ key: 'audio', value: 'audio' },
{ key: 'controlNet', value: 'controlnet' },
{ key: 'imageGeneration', value: 'image-generation' },
{ key: 'inpainting', value: 'inpainting' },
{ key: 'other', value: 'other' },
{ key: 'styleTransfer', value: 'style-transfer' },
{ key: 'text', value: 'text' },
{ key: 'upscaling', value: 'upscaling' },
{ key: 'videoGeneration', value: 'video-generation' }
] as const
const TAG_SUGGESTIONS = [
'stable-diffusion',
'flux',
'sdxl',
'sd1.5',
'img2img',
'txt2img',
'upscale',
'face-restore',
'animation',
'video',
'lora',
'controlnet',
'ipadapter',
'inpainting',
'outpainting',
'depth',
'pose',
'segmentation',
'latent',
'sampler'
]
const tagQuery = ref('')
const showSuggestions = ref(false)
const filteredSuggestions = computed(() => {
const query = tagQuery.value.toLowerCase().trim()
if (!query) return []
const existing = ctx.template.value.tags ?? []
return TAG_SUGGESTIONS.filter(
(s) => s.includes(query) && !existing.includes(s)
)
})
function toggleCategory(value: string) {
const categories = ctx.template.value.categories ?? []
const index = categories.indexOf(value)
if (index >= 0) {
categories.splice(index, 1)
} else {
categories.push(value)
}
ctx.template.value.categories = [...categories]
}
function addTag(tag: string) {
const trimmed = tag.trim().toLowerCase()
if (!trimmed) return
const tags = ctx.template.value.tags ?? []
if (!tags.includes(trimmed)) {
ctx.template.value.tags = [...tags, trimmed]
}
tagQuery.value = ''
showSuggestions.value = false
}
function removeTag(tag: string) {
const tags = ctx.template.value.tags ?? []
ctx.template.value.tags = tags.filter((t) => t !== tag)
}
watchDebounced(
() => ctx.template.value,
() => ctx.saveDraft(),
{ deep: true, debounce: 500 }
)
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div class="flex flex-col gap-6 p-6">
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.complete.description') }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,111 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingDescription from './StepTemplatePublishingDescription.vue'
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((md: string) => `<p>${md}</p>`)
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
description: {
title: 'Description',
description: 'Write a detailed description of your template',
editorLabel: 'Description (Markdown)',
previewLabel: 'Description (Render preview)'
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(3)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingDescription, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingDescription', () => {
it('renders editor and preview labels', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Description (Markdown)')
expect(wrapper.text()).toContain('Description (Render preview)')
})
it('renders a textarea for markdown editing', () => {
const { wrapper } = mountStep()
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
})
it('binds textarea to template.description', () => {
const ctx = createContext({ description: 'Hello **world**' })
const { wrapper } = mountStep(ctx)
const textarea = wrapper.find('textarea')
expect((textarea.element as HTMLTextAreaElement).value).toBe(
'Hello **world**'
)
})
it('updates template.description when textarea changes', async () => {
const ctx = createContext({ description: '' })
const { wrapper } = mountStep(ctx)
const textarea = wrapper.find('textarea')
await textarea.setValue('New content')
expect(ctx.template.value.description).toBe('New content')
})
it('renders markdown preview from template.description', () => {
const ctx = createContext({ description: 'Some markdown' })
const { wrapper } = mountStep(ctx)
const preview = wrapper.find('[class*="prose"]')
expect(preview.html()).toContain('<p>Some markdown</p>')
})
it('renders empty preview when description is undefined', () => {
const ctx = createContext({})
const { wrapper } = mountStep(ctx)
const preview = wrapper.find('[class*="prose"]')
expect(preview.html()).toContain('<p></p>')
})
})

View File

@@ -0,0 +1,47 @@
<template>
<div class="flex h-full flex-row gap-4 p-6">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<label for="tpl-description-editor" class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.description.editorLabel') }}
</label>
<textarea
id="tpl-description-editor"
v-model="ctx.template.value.description"
class="min-h-0 flex-1 resize-none rounded-lg border border-border-default bg-secondary-background p-3 font-mono text-sm text-base-foreground focus:outline-none"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.description.previewLabel') }}
</span>
<div
class="prose prose-invert min-h-0 flex-1 overflow-y-auto rounded-lg border border-border-default bg-secondary-background p-3 text-sm scrollbar-custom"
v-html="renderedHtml"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { PublishingStepperKey } from '../types'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const renderedHtml = computed(() =>
renderMarkdownToHtml(ctx.template.value.description ?? '')
)
watchDebounced(
() => ctx.template.value,
() => ctx.saveDraft(),
{ deep: true, debounce: 500 }
)
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="flex flex-col gap-6 p-6">
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.landing.description') }}
</p>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const emit = defineEmits<{
'update:valid': [valid: boolean]
}>()
onMounted(() => {
emit('update:valid', true)
})
</script>

View File

@@ -0,0 +1,299 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import { NodeSourceType } from '@/types/nodeSource'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingMetadata from './StepTemplatePublishingMetadata.vue'
const mockNodes = vi.hoisted(() => [
{ type: 'KSampler', isSubgraphNode: () => false },
{ type: 'MyCustomNode', isSubgraphNode: () => false },
{ type: 'AnotherCustom', isSubgraphNode: () => false },
{ type: 'MyCustomNode', isSubgraphNode: () => false },
{ type: 'ExtraCustomPack', isSubgraphNode: () => false }
])
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
const typedActual = actual as {
watchDebounced: (...args: unknown[]) => unknown
}
return typedActual.watchDebounced(source, cb, {
...(opts as object),
debounce: 0
})
})
}
})
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
nodes: mockNodes
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
mapAllNodes: vi.fn(
(
graph: { nodes: Array<{ type: string }> },
mapFn: (node: { type: string }) => string | undefined
) => graph.nodes.map(mapFn).filter(Boolean)
)
}))
vi.mock('@/composables/useVramEstimation', () => ({
estimateWorkflowVram: vi.fn(() => 5_000_000_000)
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeDefsByName: {
KSampler: {
name: 'KSampler',
python_module: 'nodes',
nodeSource: { type: NodeSourceType.Core }
},
MyCustomNode: {
name: 'MyCustomNode',
python_module: 'custom_nodes.MyPack@1.0.nodes',
nodeSource: { type: NodeSourceType.CustomNodes }
},
AnotherCustom: {
name: 'AnotherCustom',
python_module: 'custom_nodes.MyPack@1.0.extra',
nodeSource: { type: NodeSourceType.CustomNodes }
},
ExtraCustomPack: {
name: 'ExtraCustomPack',
python_module: 'custom_nodes.ExtraPack.nodes',
nodeSource: { type: NodeSourceType.CustomNodes }
},
UnusedCustomNode: {
name: 'UnusedCustomNode',
python_module: 'custom_nodes.UnusedPack@2.0.nodes',
nodeSource: { type: NodeSourceType.CustomNodes }
}
}
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
metadata: {
title: 'Metadata',
description: 'Title, description, and author info',
titleLabel: 'Title',
difficultyLabel: 'Difficulty',
licenseLabel: 'License',
requiredNodesLabel: 'Custom Nodes',
requiredNodesDetected: 'Detected from workflow',
requiredNodesManualPlaceholder: 'Add custom node name…',
requiredNodesManualLabel: 'Additional custom nodes',
vramLabel: 'Estimated VRAM Requirement',
vramAutoDetected: 'Auto-detected from workflow:',
vramManualOverride: 'Manual override (GB):',
difficulty: {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced'
},
license: {
mit: 'MIT',
ccBy: 'CC BY',
ccBySa: 'CC BY-SA',
ccByNc: 'CC BY-NC',
apache: 'Apache',
custom: 'Custom'
}
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(2)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingMetadata, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context },
stubs: {
FormItem: {
template:
'<div :data-testid="`form-item-${id}`"><input :value="formValue" @input="$emit(\'update:formValue\', $event.target.value)" /></div>',
props: ['item', 'id', 'formValue', 'labelClass'],
emits: ['update:formValue']
}
}
}
}),
ctx: context
}
}
describe('StepTemplatePublishingMetadata', () => {
it('renders all form fields', () => {
const { wrapper } = mountStep()
expect(wrapper.find('#tpl-title').exists()).toBe(true)
expect(wrapper.text()).toContain('Difficulty')
expect(wrapper.find('[data-testid="form-item-tpl-license"]').exists()).toBe(
true
)
})
it('selects difficulty when radio button is clicked', async () => {
const ctx = createContext({})
const { wrapper } = mountStep(ctx)
const intermediateRadio = wrapper.find('#tpl-difficulty-intermediate')
await intermediateRadio.setValue(true)
expect(ctx.template.value.difficulty).toBe('intermediate')
})
it('displays detected custom nodes from the workflow', async () => {
const { wrapper } = mountStep()
await nextTick()
expect(wrapper.text()).toContain('AnotherCustom')
expect(wrapper.text()).toContain('MyCustomNode')
expect(wrapper.text()).not.toContain('KSampler')
})
it('populates requiredNodes on mount when empty', () => {
const ctx = createContext({ requiredNodes: [] })
mountStep(ctx)
expect(ctx.template.value.requiredNodes).toContain('AnotherCustom')
expect(ctx.template.value.requiredNodes).toContain('MyCustomNode')
expect(ctx.template.value.requiredNodes).not.toContain('KSampler')
})
it('does not overwrite existing requiredNodes on mount', () => {
const ctx = createContext({ requiredNodes: ['PreExisting'] })
mountStep(ctx)
expect(ctx.template.value.requiredNodes).toEqual(['PreExisting'])
})
it('populates requiresCustomNodes with deduplicated package IDs on mount', () => {
const ctx = createContext({})
mountStep(ctx)
// MyCustomNode and AnotherCustom both come from MyPack@1.0 (@ stripped)
// ExtraCustomPack comes from ExtraPack (no @version in module path)
expect(ctx.template.value.requiresCustomNodes).toEqual([
'ExtraPack',
'MyPack'
])
})
it('does not overwrite existing requiresCustomNodes on mount', () => {
const ctx = createContext({ requiresCustomNodes: ['PreExisting'] })
mountStep(ctx)
expect(ctx.template.value.requiresCustomNodes).toEqual(['PreExisting'])
})
it('adds a manual custom node via the input', async () => {
const ctx = createContext({ requiredNodes: [] })
const { wrapper } = mountStep(ctx)
const input = wrapper.find('.relative input[type="text"]')
await input.setValue('ManualNode')
await input.trigger('keydown.enter')
expect(ctx.template.value.requiredNodes).toContain('ManualNode')
})
it('removes a manual custom node when its remove button is clicked', async () => {
const ctx = createContext({
requiredNodes: ['AnotherCustom', 'MyCustomNode', 'ManualNode']
})
const { wrapper } = mountStep(ctx)
const removeButtons = wrapper.findAll(
'button[aria-label="Remove ManualNode"]'
)
await removeButtons[0].trigger('click')
expect(ctx.template.value.requiredNodes).not.toContain('ManualNode')
})
it('shows filtered custom node suggestions when typing', async () => {
const ctx = createContext({ requiredNodes: [] })
const { wrapper } = mountStep(ctx)
const input = wrapper.find('.relative input[type="text"]')
await input.trigger('focus')
await input.setValue('Unused')
const suggestions = wrapper.findAll('.relative ul li')
expect(suggestions.length).toBe(1)
expect(suggestions[0].text()).toBe('UnusedCustomNode')
})
it('excludes already-added nodes from suggestions', async () => {
const ctx = createContext({ requiredNodes: ['UnusedCustomNode'] })
const { wrapper } = mountStep(ctx)
const input = wrapper.find('.relative input[type="text"]')
await input.trigger('focus')
await input.setValue('Unused')
const suggestions = wrapper.findAll('.relative ul li')
expect(suggestions.length).toBe(0)
})
it('adds a node from the suggestion dropdown', async () => {
const ctx = createContext({ requiredNodes: [] })
const { wrapper } = mountStep(ctx)
const input = wrapper.find('.relative input[type="text"]')
await input.trigger('focus')
await input.setValue('Unused')
const suggestion = wrapper.find('.relative ul li')
await suggestion.trigger('mousedown')
expect(ctx.template.value.requiredNodes).toContain('UnusedCustomNode')
})
})

View File

@@ -0,0 +1,384 @@
<template>
<div class="flex flex-col gap-6 p-6">
<div class="flex flex-row items-center gap-2">
<div class="form-label flex w-28 shrink-0 items-center">
<span id="tpl-title-label" class="text-muted">
{{ t('templatePublishing.steps.metadata.titleLabel') }}
</span>
</div>
<input
id="tpl-title"
v-model="ctx.template.value.title"
type="text"
class="h-8 w-[100em] rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
aria-labelledby="tpl-title-label"
/>
</div>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex w-28 shrink-0 items-center">
<span id="tpl-difficulty-label" class="text-muted">
{{ t('templatePublishing.steps.metadata.difficultyLabel') }}
</span>
</div>
<div
class="flex flex-row gap-4"
role="radiogroup"
aria-labelledby="tpl-difficulty-label"
>
<label
v-for="option in DIFFICULTY_OPTIONS"
:key="option.value"
:for="`tpl-difficulty-${option.value}`"
class="flex cursor-pointer items-center gap-1.5 text-sm"
>
<input
:id="`tpl-difficulty-${option.value}`"
type="radio"
name="tpl-difficulty"
:value="option.value"
:checked="ctx.template.value.difficulty === option.value"
:class="
cn(
'h-5 w-5 appearance-none rounded-full border-2 checked:bg-current checked:shadow-[inset_0_0_0_1px_white]',
option.borderClass
)
"
@change="ctx.template.value.difficulty = option.value"
/>
{{ option.text }}
</label>
</div>
</div>
<FormItem
id="tpl-license"
v-model:form-value="ctx.template.value.license"
:item="licenseField"
/>
<div class="flex flex-col gap-2">
<span id="tpl-required-nodes-label" class="text-sm text-muted">
{{ t('templatePublishing.steps.metadata.requiredNodesLabel') }}
</span>
<div
v-if="detectedCustomNodes.length > 0"
aria-labelledby="tpl-required-nodes-label"
>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.metadata.requiredNodesDetected') }}
</span>
<ul class="mt-1 flex flex-col gap-1">
<li
v-for="nodeName in detectedCustomNodes"
:key="nodeName"
class="flex items-center gap-2 rounded bg-secondary-background px-2 py-1 text-sm"
>
<i
class="icon-[lucide--puzzle] h-3.5 w-3.5 text-muted-foreground"
/>
{{ nodeName }}
</li>
</ul>
</div>
<div>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.metadata.requiredNodesManualLabel') }}
</span>
<div class="relative mt-1">
<input
v-model="manualNodeQuery"
type="text"
class="h-8 w-56 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
:placeholder="
t(
'templatePublishing.steps.metadata.requiredNodesManualPlaceholder'
)
"
@focus="showNodeSuggestions = true"
@keydown.enter.prevent="addManualNode(manualNodeQuery)"
/>
<ul
v-if="showNodeSuggestions && filteredNodeSuggestions.length > 0"
class="absolute z-10 mt-1 max-h-40 w-56 overflow-auto rounded border border-border-default bg-secondary-background shadow-md"
>
<li
v-for="suggestion in filteredNodeSuggestions"
:key="suggestion"
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
@mousedown.prevent="addManualNode(suggestion)"
>
{{ suggestion }}
</li>
</ul>
</div>
<div
v-if="manualNodes.length > 0"
class="mt-1 flex flex-wrap items-center gap-1"
>
<span
v-for="node in manualNodes"
:key="node"
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
>
{{ node }}
<button
type="button"
class="hover:text-danger"
:aria-label="`Remove ${node}`"
@click="removeManualNode(node)"
>
<i class="icon-[lucide--x] h-3 w-3" />
</button>
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<span id="tpl-vram-label" class="text-sm text-muted">
{{ t('templatePublishing.steps.metadata.vramLabel') }}
</span>
<div class="flex items-center gap-3">
<i class="icon-[lucide--cpu] h-3.5 w-3.5 text-muted-foreground" />
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.metadata.vramAutoDetected') }}
</span>
<span class="text-sm font-medium">
{{ formatSize(autoDetectedVram) }}
</span>
</div>
<div class="flex items-center gap-2">
<input
id="tpl-vram-override"
v-model.number="manualVramGb"
type="number"
min="0"
step="0.5"
class="h-8 w-24 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
aria-labelledby="tpl-vram-label"
/>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.metadata.vramManualOverride') }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { formatSize } from '@/utils/formatUtil'
import { useI18n } from 'vue-i18n'
import FormItem from '@/components/common/FormItem.vue'
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
import type { FormItem as FormItemType } from '@/platform/settings/types'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { PublishingStepperKey } from '../types'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const nodeDefStore = useNodeDefStore()
const DIFFICULTY_OPTIONS = [
{
text: t('templatePublishing.steps.metadata.difficulty.beginner'),
value: 'beginner' as const,
borderClass: 'border-green-400'
},
{
text: t('templatePublishing.steps.metadata.difficulty.intermediate'),
value: 'intermediate' as const,
borderClass: 'border-amber-400'
},
{
text: t('templatePublishing.steps.metadata.difficulty.advanced'),
value: 'advanced' as const,
borderClass: 'border-red-400'
}
]
const licenseField: FormItemType = {
name: t('templatePublishing.steps.metadata.licenseLabel'),
type: 'combo',
options: [
{ text: t('templatePublishing.steps.metadata.license.mit'), value: 'mit' },
{
text: t('templatePublishing.steps.metadata.license.ccBy'),
value: 'cc-by'
},
{
text: t('templatePublishing.steps.metadata.license.ccBySa'),
value: 'cc-by-sa'
},
{
text: t('templatePublishing.steps.metadata.license.ccByNc'),
value: 'cc-by-nc'
},
{
text: t('templatePublishing.steps.metadata.license.apache'),
value: 'apache'
},
{
text: t('templatePublishing.steps.metadata.license.custom'),
value: 'custom'
}
],
attrs: { filter: true }
}
/**
* Collects unique custom node type names from the current workflow graph.
* Excludes core, essentials, and blueprint nodes.
*/
function detectCustomNodes(): string[] {
if (!app.rootGraph) return []
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
const unique = new Set(nodeTypes)
return [...unique]
.filter((type) => {
const def = nodeDefStore.nodeDefsByName[type]
if (!def) return false
return def.nodeSource.type === NodeSourceType.CustomNodes
})
.sort()
}
/**
* Extracts the custom node package ID from a `python_module` string.
*
* Custom node modules follow the pattern
* `custom_nodes.PackageName@version.submodule`, so the package ID is the
* second dot-segment with the `@version` suffix stripped.
*
* @returns The package folder name, or `undefined` when the module does not
* match the expected pattern.
*/
function extractPackageId(pythonModule: string): string | undefined {
const segments = pythonModule.split('.')
if (segments[0] !== 'custom_nodes' || !segments[1]) return undefined
return segments[1].split('@')[0]
}
/**
* Collects unique custom node package IDs from the current workflow graph.
*/
function detectCustomNodePackages(): string[] {
if (!app.rootGraph) return []
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
const packages = new Set<string>()
for (const type of nodeTypes) {
const def = nodeDefStore.nodeDefsByName[type]
if (!def || def.nodeSource.type !== NodeSourceType.CustomNodes) continue
const pkgId = extractPackageId(def.python_module)
if (pkgId) packages.add(pkgId)
}
return [...packages].sort()
}
const detectedCustomNodes = ref<string[]>([])
const autoDetectedVram = ref(0)
const GB = 1_073_741_824
/**
* Manual VRAM override in GB. When set to a positive number, this
* value (converted to bytes) takes precedence over the auto-detected
* estimate for `vramRequirement`.
*/
const manualVramGb = computed({
get: () => {
const stored = ctx.template.value.vramRequirement
if (!stored || stored === autoDetectedVram.value) return undefined
return Math.round((stored / GB) * 10) / 10
},
set: (gb: number | undefined) => {
if (gb && gb > 0) {
ctx.template.value.vramRequirement = Math.round(gb * GB)
} else {
ctx.template.value.vramRequirement = autoDetectedVram.value
}
}
})
onMounted(() => {
detectedCustomNodes.value = detectCustomNodes()
const existing = ctx.template.value.requiredNodes ?? []
if (existing.length === 0) {
ctx.template.value.requiredNodes = [...detectedCustomNodes.value]
}
const existingPackages = ctx.template.value.requiresCustomNodes ?? []
if (existingPackages.length === 0) {
ctx.template.value.requiresCustomNodes = detectCustomNodePackages()
}
autoDetectedVram.value = estimateWorkflowVram(app.rootGraph)
if (!ctx.template.value.vramRequirement) {
ctx.template.value.vramRequirement = autoDetectedVram.value
}
})
const manualNodes = computed(() => {
const all = ctx.template.value.requiredNodes ?? []
const detected = new Set(detectedCustomNodes.value)
return all.filter((n) => !detected.has(n))
})
const manualNodeQuery = ref('')
const showNodeSuggestions = ref(false)
/** All installed custom node type names for searchable suggestions. */
const allCustomNodeNames = computed(() =>
Object.values(nodeDefStore.nodeDefsByName)
.filter((def) => def.nodeSource.type === NodeSourceType.CustomNodes)
.map((def) => def.name)
.sort()
)
const filteredNodeSuggestions = computed(() => {
const query = manualNodeQuery.value.toLowerCase().trim()
if (!query) return []
const existing = new Set(ctx.template.value.requiredNodes ?? [])
return allCustomNodeNames.value.filter(
(name) => name.toLowerCase().includes(query) && !existing.has(name)
)
})
function addManualNode(name: string) {
const trimmed = name.trim()
if (!trimmed) return
const nodes = ctx.template.value.requiredNodes ?? []
if (!nodes.includes(trimmed)) {
ctx.template.value.requiredNodes = [...nodes, trimmed]
}
manualNodeQuery.value = ''
showNodeSuggestions.value = false
}
function removeManualNode(name: string) {
const nodes = ctx.template.value.requiredNodes ?? []
ctx.template.value.requiredNodes = nodes.filter((n) => n !== name)
}
watchDebounced(
() => ctx.template.value,
() => ctx.saveDraft(),
{ deep: true, debounce: 500 }
)
</script>

View File

@@ -0,0 +1,288 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingPreview from './StepTemplatePublishingPreview.vue'
let blobCounter = 0
URL.createObjectURL = vi.fn(() => `blob:http://localhost/mock-${++blobCounter}`)
URL.revokeObjectURL = vi.fn()
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((md: string) => `<p>${md}</p>`)
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
metadata: {
titleLabel: 'Title',
difficultyLabel: 'Difficulty',
licenseLabel: 'License',
categoryLabel: 'Categories',
tagsLabel: 'Tags',
requiredNodesLabel: 'Custom Nodes',
difficulty: {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced'
},
license: {
mit: 'MIT',
ccBy: 'CC BY',
ccBySa: 'CC BY-SA',
ccByNc: 'CC BY-NC',
apache: 'Apache',
custom: 'Custom'
},
category: {
imageGeneration: 'Image Generation',
videoGeneration: 'Video Generation',
audio: 'Audio',
text: 'Text',
threeD: '3D',
upscaling: 'Upscaling',
inpainting: 'Inpainting',
controlNet: 'ControlNet',
styleTransfer: 'Style Transfer',
other: 'Other'
}
},
preview: {
sectionMetadata: 'Metadata',
sectionDescription: 'Description',
sectionPreviewAssets: 'Preview Assets',
sectionCategoriesAndTags: 'Categories & Tags',
thumbnailLabel: 'Thumbnail',
comparisonLabel: 'Before & After',
workflowPreviewLabel: 'Workflow Graph',
videoPreviewLabel: 'Video Preview',
galleryLabel: 'Gallery',
notProvided: 'Not provided',
noneDetected: 'None detected',
correct: 'Correct',
editStep: 'Edit'
},
previewGeneration: {
beforeImageLabel: 'Before',
afterImageLabel: 'After'
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(6)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingPreview, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingPreview', () => {
beforeEach(() => {
const assets = useTemplatePreviewAssets()
assets.clearAll()
vi.clearAllMocks()
blobCounter = 0
})
it('renders all section headings', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Metadata')
expect(wrapper.text()).toContain('Description')
expect(wrapper.text()).toContain('Preview Assets')
expect(wrapper.text()).toContain('Categories & Tags')
})
it('displays template title', () => {
const ctx = createContext({ title: 'My Workflow' })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('My Workflow')
})
it('displays difficulty level', () => {
const ctx = createContext({ difficulty: 'advanced' })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('Advanced')
})
it('displays license type', () => {
const ctx = createContext({ license: 'mit' })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('MIT')
})
it('displays required custom nodes', () => {
const ctx = createContext({
requiredNodes: ['ComfyUI-Impact-Pack', 'ComfyUI-Manager']
})
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('ComfyUI-Impact-Pack')
expect(wrapper.text()).toContain('ComfyUI-Manager')
})
it('shows "None detected" when no custom nodes', () => {
const ctx = createContext({ requiredNodes: [] })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('None detected')
})
it('renders description as markdown HTML', () => {
const ctx = createContext({ description: 'Hello **bold**' })
const { wrapper } = mountStep(ctx)
const prose = wrapper.find('[class*="prose"]')
expect(prose.html()).toContain('<p>Hello **bold**</p>')
})
it('shows "Not provided" when description is empty', () => {
const ctx = createContext({})
const { wrapper } = mountStep(ctx)
const text = wrapper.text()
expect(text).toContain('Not provided')
})
it('displays categories as pills', () => {
const ctx = createContext({
categories: ['image-generation', 'controlnet']
})
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('Image Generation')
expect(wrapper.text()).toContain('ControlNet')
})
it('displays tags as pills', () => {
const ctx = createContext({ tags: ['flux', 'sdxl'] })
const { wrapper } = mountStep(ctx)
expect(wrapper.text()).toContain('flux')
expect(wrapper.text()).toContain('sdxl')
})
it('displays thumbnail when asset is cached', () => {
const assets = useTemplatePreviewAssets()
assets.setThumbnail(new File([''], 'thumb.png'))
const ctx = createContext({ thumbnail: 'blob:thumb' })
const { wrapper } = mountStep(ctx)
const imgs = wrapper.findAll('img')
const thumbImg = imgs.find((img) =>
img.attributes('alt')?.includes('thumb.png')
)
expect(thumbImg?.exists()).toBe(true)
})
it('displays gallery images when assets are cached', () => {
const assets = useTemplatePreviewAssets()
assets.addGalleryImage(new File([''], 'a.png'))
assets.addGalleryImage(new File([''], 'b.png'))
const ctx = createContext({
gallery: [
{ type: 'image', url: 'blob:a', caption: 'a.png' },
{ type: 'image', url: 'blob:b', caption: 'b.png' }
]
})
const { wrapper } = mountStep(ctx)
const imgs = wrapper.findAll('img')
const galleryImgs = imgs.filter(
(img) =>
img.attributes('alt') === 'a.png' || img.attributes('alt') === 'b.png'
)
expect(galleryImgs).toHaveLength(2)
})
it('renders a "Correct" button', () => {
const { wrapper } = mountStep()
const correctBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Correct'))
expect(correctBtn?.exists()).toBe(true)
})
it('calls nextStep when "Correct" button is clicked', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const correctBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Correct'))
await correctBtn!.trigger('click')
expect(ctx.nextStep).toHaveBeenCalled()
})
it('navigates to metadata step when edit is clicked on metadata section', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const editButtons = wrapper
.findAll('button')
.filter((b) => b.text().includes('Edit'))
await editButtons[0].trigger('click')
expect(ctx.goToStep).toHaveBeenCalledWith(2)
})
it('navigates to description step when edit is clicked on description section', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const editButtons = wrapper
.findAll('button')
.filter((b) => b.text().includes('Edit'))
await editButtons[1].trigger('click')
expect(ctx.goToStep).toHaveBeenCalledWith(3)
})
it('navigates to preview generation step when edit is clicked on assets section', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const editButtons = wrapper
.findAll('button')
.filter((b) => b.text().includes('Edit'))
await editButtons[2].trigger('click')
expect(ctx.goToStep).toHaveBeenCalledWith(4)
})
it('navigates to category step when edit is clicked on categories section', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const editButtons = wrapper
.findAll('button')
.filter((b) => b.text().includes('Edit'))
await editButtons[3].trigger('click')
expect(ctx.goToStep).toHaveBeenCalledWith(5)
})
})

View File

@@ -0,0 +1,298 @@
<!--
Step 6 of the template publishing wizard. Displays a read-only summary
of all user-provided data so the author can audit it before submission.
-->
<template>
<div class="flex flex-col gap-6 overflow-y-auto p-6">
<!-- Metadata -->
<PreviewSection
:label="t('templatePublishing.steps.preview.sectionMetadata')"
@edit="ctx.goToStep(2)"
>
<PreviewField
:label="t('templatePublishing.steps.metadata.titleLabel')"
:value="tpl.title"
/>
<PreviewField
:label="t('templatePublishing.steps.metadata.difficultyLabel')"
:value="difficultyLabel"
/>
<PreviewField
:label="t('templatePublishing.steps.metadata.licenseLabel')"
:value="licenseLabel"
/>
<PreviewField
:label="t('templatePublishing.steps.metadata.requiredNodesLabel')"
>
<ul
v-if="(tpl.requiredNodes ?? []).length > 0"
class="flex flex-col gap-0.5"
>
<li
v-for="node in tpl.requiredNodes"
:key="node"
class="flex items-center gap-1.5 text-sm"
>
<i
class="icon-[lucide--puzzle] h-3.5 w-3.5 text-muted-foreground"
/>
{{ node }}
</li>
</ul>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.noneDetected') }}
</span>
</PreviewField>
<PreviewField
:label="t('templatePublishing.steps.preview.vramLabel')"
:value="vramLabel"
/>
</PreviewSection>
<!-- Description -->
<PreviewSection
:label="t('templatePublishing.steps.preview.sectionDescription')"
@edit="ctx.goToStep(3)"
>
<div
v-if="tpl.description"
class="prose prose-invert max-h-48 overflow-y-auto rounded-lg border border-border-default bg-secondary-background p-3 text-sm scrollbar-custom"
v-html="renderedDescription"
/>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewSection>
<!-- Preview Assets -->
<PreviewSection
:label="t('templatePublishing.steps.preview.sectionPreviewAssets')"
@edit="ctx.goToStep(4)"
>
<!-- Thumbnail -->
<PreviewField
:label="t('templatePublishing.steps.preview.thumbnailLabel')"
>
<img
v-if="assets.thumbnail.value"
:src="assets.thumbnail.value.objectUrl"
:alt="assets.thumbnail.value.originalName"
class="h-28 w-44 rounded-lg object-cover"
/>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<!-- Before & After -->
<PreviewField
:label="t('templatePublishing.steps.preview.comparisonLabel')"
>
<div
v-if="assets.beforeImage.value || assets.afterImage.value"
class="flex gap-3"
>
<div v-if="assets.beforeImage.value" class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.beforeImageLabel')
}}
</span>
<img
:src="assets.beforeImage.value.objectUrl"
:alt="assets.beforeImage.value.originalName"
class="h-24 w-24 rounded-lg object-cover"
/>
</div>
<div v-if="assets.afterImage.value" class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.afterImageLabel')
}}
</span>
<img
:src="assets.afterImage.value.objectUrl"
:alt="assets.afterImage.value.originalName"
class="h-24 w-24 rounded-lg object-cover"
/>
</div>
</div>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<!-- Workflow Graph -->
<PreviewField
:label="t('templatePublishing.steps.preview.workflowPreviewLabel')"
>
<img
v-if="assets.workflowPreview.value"
:src="assets.workflowPreview.value.objectUrl"
:alt="assets.workflowPreview.value.originalName"
class="h-28 w-48 rounded-lg object-cover"
/>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<!-- Video Preview -->
<PreviewField
:label="t('templatePublishing.steps.preview.videoPreviewLabel')"
>
<video
v-if="assets.videoPreview.value"
:src="assets.videoPreview.value.objectUrl"
controls
class="h-28 w-48 rounded-lg"
/>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<!-- Gallery -->
<PreviewField :label="t('templatePublishing.steps.preview.galleryLabel')">
<div
v-if="assets.galleryImages.value.length > 0"
class="flex flex-wrap gap-2"
>
<img
v-for="(img, i) in assets.galleryImages.value"
:key="img.originalName + i"
:src="img.objectUrl"
:alt="img.originalName"
class="h-20 w-20 rounded-lg object-cover"
/>
</div>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
</PreviewSection>
<!-- Categories & Tags -->
<PreviewSection
:label="t('templatePublishing.steps.preview.sectionCategoriesAndTags')"
@edit="ctx.goToStep(5)"
>
<PreviewField
:label="t('templatePublishing.steps.metadata.categoryLabel')"
>
<div
v-if="(tpl.categories ?? []).length > 0"
class="flex flex-wrap gap-1"
>
<span
v-for="cat in tpl.categories"
:key="cat"
class="rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
>
{{ categoryDisplayName(cat) }}
</span>
</div>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
<PreviewField :label="t('templatePublishing.steps.metadata.tagsLabel')">
<div v-if="(tpl.tags ?? []).length > 0" class="flex flex-wrap gap-1">
<span
v-for="tag in tpl.tags"
:key="tag"
class="rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
>
{{ tag }}
</span>
</div>
<span v-else class="text-sm text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</PreviewField>
</PreviewSection>
<!-- Correct button -->
<div class="flex justify-end pt-2">
<Button size="lg" @click="ctx.nextStep()">
<i class="icon-[lucide--check] mr-1" />
{{ t('templatePublishing.steps.preview.correct') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { formatSize } from '@/utils/formatUtil'
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LicenseType } from '@/types/templateMarketplace'
import Button from '@/components/ui/button/Button.vue'
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { PublishingStepperKey } from '../types'
import PreviewField from './preview/PreviewField.vue'
import PreviewSection from './preview/PreviewSection.vue'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const assets = useTemplatePreviewAssets()
const tpl = computed(() => ctx.template.value)
const renderedDescription = computed(() =>
renderMarkdownToHtml(tpl.value.description ?? '')
)
const CATEGORY_KEY_MAP: Record<string, string> = {
'3d': 'threeD',
audio: 'audio',
controlnet: 'controlNet',
'image-generation': 'imageGeneration',
inpainting: 'inpainting',
other: 'other',
'style-transfer': 'styleTransfer',
text: 'text',
upscaling: 'upscaling',
'video-generation': 'videoGeneration'
}
function categoryDisplayName(value: string): string {
const key = CATEGORY_KEY_MAP[value]
if (!key) return value
return t(`templatePublishing.steps.metadata.category.${key}`)
}
const LICENSE_KEY_MAP: Record<string, string> = {
mit: 'mit',
'cc-by': 'ccBy',
'cc-by-sa': 'ccBySa',
'cc-by-nc': 'ccByNc',
apache: 'apache',
custom: 'custom'
}
const licenseLabel = computed(() => {
const license = tpl.value.license
if (!license) return t('templatePublishing.steps.preview.notProvided')
const key = LICENSE_KEY_MAP[license as LicenseType]
if (!key) return license
return t(`templatePublishing.steps.metadata.license.${key}`)
})
const difficultyLabel = computed(() => {
const difficulty = tpl.value.difficulty
if (!difficulty) return t('templatePublishing.steps.preview.notProvided')
return t(`templatePublishing.steps.metadata.difficulty.${difficulty}`)
})
const vramLabel = computed(() => {
const vram = tpl.value.vramRequirement
if (!vram) return t('templatePublishing.steps.preview.notProvided')
return formatSize(vram)
})
</script>

View File

@@ -0,0 +1,239 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplatePreviewAssets } from '@/composables/useTemplatePreviewAssets'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingPreviewGeneration from './StepTemplatePublishingPreviewGeneration.vue'
let blobCounter = 0
URL.createObjectURL = vi.fn(() => `blob:http://localhost/mock-${++blobCounter}`)
URL.revokeObjectURL = vi.fn()
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
const typedActual = actual as {
watchDebounced: (...args: unknown[]) => unknown
}
return typedActual.watchDebounced(source, cb, {
...(opts as object),
debounce: 0
})
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
steps: {
previewGeneration: {
thumbnailLabel: 'Thumbnail',
thumbnailHint: 'Primary image shown in marketplace listings',
comparisonLabel: 'Before & After Comparison',
comparisonHint: 'Show what the workflow transforms',
beforeImageLabel: 'Before',
afterImageLabel: 'After',
workflowPreviewLabel: 'Workflow Graph',
workflowPreviewHint: 'Screenshot of the workflow graph layout',
videoPreviewLabel: 'Video Preview',
videoPreviewHint: 'Optional short video demonstrating the workflow',
galleryLabel: 'Example Gallery',
galleryHint: 'Up to {max} example output images',
uploadPrompt: 'Click to upload',
removeFile: 'Remove'
}
}
}
}
}
})
function createContext(
templateData: Partial<MarketplaceTemplate> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>(templateData)
const currentStep = ref(4)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn()
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingPreviewGeneration, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingPreviewGeneration', () => {
beforeEach(() => {
const assets = useTemplatePreviewAssets()
assets.clearAll()
vi.clearAllMocks()
blobCounter = 0
})
it('renders all upload sections', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Thumbnail')
expect(wrapper.text()).toContain('Before & After Comparison')
expect(wrapper.text()).toContain('Workflow Graph')
expect(wrapper.text()).toContain('Video Preview')
expect(wrapper.text()).toContain('Example Gallery')
})
it('renders before and after upload zones side by side', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Before')
expect(wrapper.text()).toContain('After')
})
it('updates template thumbnail on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[0].vm.$emit('upload', new File([''], 'thumb.png'))
expect(ctx.template.value.thumbnail).toMatch(/^blob:/)
})
it('clears template thumbnail on remove', () => {
const assets = useTemplatePreviewAssets()
assets.setThumbnail(new File([''], 'thumb.png'))
const ctx = createContext({ thumbnail: 'blob:old' })
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[0].vm.$emit('remove')
expect(ctx.template.value.thumbnail).toBe('')
expect(assets.thumbnail.value).toBeNull()
})
it('updates template beforeImage on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[1].vm.$emit('upload', new File([''], 'before.png'))
expect(ctx.template.value.beforeImage).toMatch(/^blob:/)
})
it('updates template afterImage on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[2].vm.$emit('upload', new File([''], 'after.png'))
expect(ctx.template.value.afterImage).toMatch(/^blob:/)
})
it('updates template workflowPreview on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[3].vm.$emit('upload', new File([''], 'graph.png'))
expect(ctx.template.value.workflowPreview).toMatch(/^blob:/)
})
it('updates template videoPreview on upload', () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const uploadZones = wrapper.findAllComponents({
name: 'TemplateAssetUploadZone'
})
uploadZones[4].vm.$emit(
'upload',
new File([''], 'demo.mp4', { type: 'video/mp4' })
)
expect(ctx.template.value.videoPreview).toMatch(/^blob:/)
})
it('shows the gallery add button when gallery is empty', () => {
const { wrapper } = mountStep()
const addButton = wrapper.find('[role="button"]')
expect(addButton.exists()).toBe(true)
})
it('adds gallery images to the template on upload', async () => {
const ctx = createContext({ gallery: [] })
const { wrapper } = mountStep(ctx)
const galleryInput = wrapper.find('input[multiple]')
const file = new File([''], 'output.png', { type: 'image/png' })
Object.defineProperty(galleryInput.element, 'files', { value: [file] })
await galleryInput.trigger('change')
expect(ctx.template.value.gallery).toHaveLength(1)
expect(ctx.template.value.gallery![0].url).toMatch(/^blob:/)
expect(ctx.template.value.gallery![0].caption).toBe('output.png')
})
it('removes a gallery image by index', async () => {
const assets = useTemplatePreviewAssets()
assets.addGalleryImage(new File([''], 'a.png'))
assets.addGalleryImage(new File([''], 'b.png'))
const ctx = createContext({
gallery: [
{ type: 'image', url: 'blob:a', caption: 'a.png' },
{ type: 'image', url: 'blob:b', caption: 'b.png' }
]
})
const { wrapper } = mountStep(ctx)
const removeButtons = wrapper.findAll('button[aria-label="Remove"]')
await removeButtons[0].trigger('click')
expect(ctx.template.value.gallery).toHaveLength(1)
expect(ctx.template.value.gallery![0].caption).toBe('b.png')
})
})

View File

@@ -0,0 +1,258 @@
<!--
Step 4 of the template publishing wizard. Collects preview assets:
thumbnail, before/after comparison, workflow graph, optional video,
and an optional gallery of up to six example output images.
-->
<template>
<div class="flex flex-col gap-6 overflow-y-auto p-6">
<!-- Thumbnail -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{ t('templatePublishing.steps.previewGeneration.thumbnailLabel') }}
</span>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.previewGeneration.thumbnailHint') }}
</span>
<TemplateAssetUploadZone
:asset="assets.thumbnail.value"
size-class="h-40 w-64"
@upload="onThumbnailUpload"
@remove="onThumbnailRemove"
/>
</section>
<!-- Before & After Comparison -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{ t('templatePublishing.steps.previewGeneration.comparisonLabel') }}
</span>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.previewGeneration.comparisonHint') }}
</span>
<div class="flex flex-row gap-4">
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.beforeImageLabel')
}}
</span>
<TemplateAssetUploadZone
:asset="assets.beforeImage.value"
@upload="onBeforeUpload"
@remove="onBeforeRemove"
/>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.afterImageLabel')
}}
</span>
<TemplateAssetUploadZone
:asset="assets.afterImage.value"
@upload="onAfterUpload"
@remove="onAfterRemove"
/>
</div>
</div>
</section>
<!-- Workflow Graph Preview -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{
t('templatePublishing.steps.previewGeneration.workflowPreviewLabel')
}}
</span>
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.workflowPreviewHint')
}}
</span>
<TemplateAssetUploadZone
:asset="assets.workflowPreview.value"
size-class="h-40 w-72"
@upload="onWorkflowUpload"
@remove="onWorkflowRemove"
/>
</section>
<!-- Video Preview (optional) -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{ t('templatePublishing.steps.previewGeneration.videoPreviewLabel') }}
</span>
<span class="text-xs text-muted-foreground">
{{ t('templatePublishing.steps.previewGeneration.videoPreviewHint') }}
</span>
<TemplateAssetUploadZone
:asset="assets.videoPreview.value"
accept="video/*"
preview-type="video"
size-class="h-40 w-72"
@upload="onVideoUpload"
@remove="onVideoRemove"
/>
</section>
<!-- Example Output Gallery -->
<section class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted">
{{ t('templatePublishing.steps.previewGeneration.galleryLabel') }}
</span>
<span class="text-xs text-muted-foreground">
{{
t('templatePublishing.steps.previewGeneration.galleryHint', {
max: MAX_GALLERY_IMAGES
})
}}
</span>
<div class="flex flex-wrap gap-3">
<div
v-for="(asset, index) in assets.galleryImages.value"
:key="asset.originalName + index"
class="group relative h-28 w-28 overflow-hidden rounded-lg"
>
<img
:src="asset.objectUrl"
:alt="asset.originalName"
class="h-full w-full object-cover"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-1.5 py-0.5 opacity-0 transition-opacity group-hover:opacity-100"
>
<span class="truncate text-[10px] text-white">
{{ asset.originalName }}
</span>
<button
type="button"
class="shrink-0 text-white hover:text-danger"
:aria-label="
t('templatePublishing.steps.previewGeneration.removeFile')
"
@click="onGalleryRemove(index)"
>
<i class="icon-[lucide--x] h-3.5 w-3.5" />
</button>
</div>
</div>
<div
v-if="assets.galleryImages.value.length < MAX_GALLERY_IMAGES"
class="flex h-28 w-28 cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-default hover:border-muted-foreground"
role="button"
:tabindex="0"
:aria-label="
t('templatePublishing.steps.previewGeneration.uploadPrompt')
"
@click="galleryInput?.click()"
@keydown.enter="galleryInput?.click()"
>
<i class="icon-[lucide--plus] h-5 w-5 text-muted-foreground" />
</div>
</div>
<input
ref="galleryInput"
type="file"
accept="image/*"
multiple
class="hidden"
@change="onGallerySelect"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { inject, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import {
MAX_GALLERY_IMAGES,
useTemplatePreviewAssets
} from '@/composables/useTemplatePreviewAssets'
import { PublishingStepperKey } from '../types'
import TemplateAssetUploadZone from '../TemplateAssetUploadZone.vue'
const { t } = useI18n()
const ctx = inject(PublishingStepperKey)!
const assets = useTemplatePreviewAssets()
const galleryInput = ref<HTMLInputElement | null>(null)
function onThumbnailUpload(file: File) {
ctx.template.value.thumbnail = assets.setThumbnail(file)
}
function onThumbnailRemove() {
assets.clearThumbnail()
ctx.template.value.thumbnail = ''
}
function onBeforeUpload(file: File) {
ctx.template.value.beforeImage = assets.setBeforeImage(file)
}
function onBeforeRemove() {
assets.clearBeforeImage()
ctx.template.value.beforeImage = undefined
}
function onAfterUpload(file: File) {
ctx.template.value.afterImage = assets.setAfterImage(file)
}
function onAfterRemove() {
assets.clearAfterImage()
ctx.template.value.afterImage = undefined
}
function onWorkflowUpload(file: File) {
ctx.template.value.workflowPreview = assets.setWorkflowPreview(file)
}
function onWorkflowRemove() {
assets.clearWorkflowPreview()
ctx.template.value.workflowPreview = ''
}
function onVideoUpload(file: File) {
ctx.template.value.videoPreview = assets.setVideoPreview(file)
}
function onVideoRemove() {
assets.clearVideoPreview()
ctx.template.value.videoPreview = undefined
}
function onGallerySelect(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (!files) return
for (const file of files) {
const url = assets.addGalleryImage(file)
if (url) {
const gallery = ctx.template.value.gallery ?? []
ctx.template.value.gallery = [
...gallery,
{ type: 'image', url, caption: file.name }
]
}
}
input.value = ''
}
function onGalleryRemove(index: number) {
assets.removeGalleryImage(index)
const gallery = ctx.template.value.gallery ?? []
ctx.template.value.gallery = gallery.filter((_, i) => i !== index)
}
watchDebounced(
() => ctx.template.value,
() => ctx.saveDraft(),
{ deep: true, debounce: 500 }
)
</script>

View File

@@ -0,0 +1,84 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepperContext } from '../types'
import { PublishingStepperKey } from '../types'
import StepTemplatePublishingSubmissionForReview from './StepTemplatePublishingSubmissionForReview.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templatePublishing: {
submit: 'Submit for Review',
steps: {
submissionForReview: {
title: 'Submit',
description: 'Submit your template for review.'
}
}
}
}
}
})
function createContext(
overrides: Partial<PublishingStepperContext> = {}
): PublishingStepperContext {
const template = ref<Partial<MarketplaceTemplate>>({})
const currentStep = ref(7)
return {
currentStep,
totalSteps: 8,
isFirstStep: computed(() => currentStep.value === 1),
isLastStep: computed(() => currentStep.value === 8),
canProceed: computed(() => false),
template,
nextStep: vi.fn(),
prevStep: vi.fn(),
goToStep: vi.fn(),
saveDraft: vi.fn(),
setStepValid: vi.fn(),
...overrides
}
}
function mountStep(ctx?: PublishingStepperContext) {
const context = ctx ?? createContext()
return {
wrapper: mount(StepTemplatePublishingSubmissionForReview, {
global: {
plugins: [i18n],
provide: { [PublishingStepperKey as symbol]: context }
}
}),
ctx: context
}
}
describe('StepTemplatePublishingSubmissionForReview', () => {
it('renders the description text', () => {
const { wrapper } = mountStep()
expect(wrapper.text()).toContain('Submit your template for review.')
})
it('renders a submit button', () => {
const { wrapper } = mountStep()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect(button.text()).toBe('Submit for Review')
})
it('calls nextStep when the submit button is clicked', async () => {
const ctx = createContext()
const { wrapper } = mountStep(ctx)
const button = wrapper.find('button')
await button.trigger('click')
expect(ctx.nextStep).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,23 @@
<template>
<div class="flex flex-col gap-6 p-6">
<p class="text-muted-foreground">
{{ t('templatePublishing.steps.submissionForReview.description') }}
</p>
<div class="flex justify-end">
<Button size="lg" @click="stepper.nextStep()">
{{ t('templatePublishing.submit') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { PublishingStepperKey } from '@/components/templatePublishing/types'
const { t } = useI18n()
const stepper = inject(PublishingStepperKey)!
</script>

View File

@@ -0,0 +1,28 @@
<!--
A labeled field within a preview section. Shows a label on the left
and either the value text or a default slot on the right.
-->
<template>
<div class="flex flex-col gap-0.5">
<span class="text-xs font-medium text-muted-foreground">{{ label }}</span>
<div class="text-sm">
<slot>
<span v-if="value">{{ value }}</span>
<span v-else class="text-muted-foreground">
{{ t('templatePublishing.steps.preview.notProvided') }}
</span>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps<{
label: string
value?: string
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,38 @@
<!--
A collapsible section in the preview step, showing a heading with an
"Edit" button that navigates back to the originating step.
-->
<template>
<section class="flex flex-col gap-3">
<div
class="flex items-center justify-between border-b border-border-default pb-1"
>
<h3 class="text-sm font-semibold text-muted">{{ label }}</h3>
<button
type="button"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-base-foreground"
@click="emit('edit')"
>
<i class="icon-[lucide--pencil] h-3 w-3" />
{{ t('templatePublishing.steps.preview.editStep') }}
</button>
</div>
<div class="flex flex-col gap-3 pl-1">
<slot />
</div>
</section>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps<{
label: string
}>()
const emit = defineEmits<{
edit: []
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,83 @@
import type { InjectionKey, Ref } from 'vue'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
/**
* Definition of a single step in the template publishing wizard.
*/
export interface PublishingStepDefinition {
/** 1-indexed step number */
number: number
/** i18n key for the step's display title */
titleKey: string
/** i18n key for the step's short description */
descriptionKey: string
}
/**
* Context shared between the publishing dialog and its step panels
* via provide/inject.
*/
export interface PublishingStepperContext {
currentStep: Readonly<Ref<number>>
totalSteps: number
isFirstStep: Readonly<Ref<boolean>>
isLastStep: Readonly<Ref<boolean>>
canProceed: Readonly<Ref<boolean>>
template: Ref<Partial<MarketplaceTemplate>>
nextStep: () => void
prevStep: () => void
goToStep: (step: number) => void
saveDraft: () => void
setStepValid: (step: number, valid: boolean) => void
}
/**
* Injection key for the publishing stepper context, allowing step panel
* components to access shared navigation and draft state.
*/
export const PublishingStepperKey: InjectionKey<PublishingStepperContext> =
Symbol('PublishingStepperContext')
export const PUBLISHING_STEP_DEFINITIONS: PublishingStepDefinition[] = [
{
number: 1,
titleKey: 'templatePublishing.steps.landing.title',
descriptionKey: 'templatePublishing.steps.landing.description'
},
{
number: 2,
titleKey: 'templatePublishing.steps.metadata.title',
descriptionKey: 'templatePublishing.steps.metadata.description'
},
{
number: 3,
titleKey: 'templatePublishing.steps.description.title',
descriptionKey: 'templatePublishing.steps.description.description'
},
{
number: 4,
titleKey: 'templatePublishing.steps.previewGeneration.title',
descriptionKey: 'templatePublishing.steps.previewGeneration.description'
},
{
number: 5,
titleKey: 'templatePublishing.steps.categoryAndTagging.title',
descriptionKey: 'templatePublishing.steps.categoryAndTagging.description'
},
{
number: 6,
titleKey: 'templatePublishing.steps.preview.title',
descriptionKey: 'templatePublishing.steps.preview.description'
},
{
number: 7,
titleKey: 'templatePublishing.steps.submissionForReview.title',
descriptionKey: 'templatePublishing.steps.submissionForReview.description'
},
{
number: 8,
titleKey: 'templatePublishing.steps.complete.title',
descriptionKey: 'templatePublishing.steps.complete.description'
}
]

View File

@@ -307,6 +307,18 @@ describe('useCoreCommands', () => {
})
})
describe('ShowDeveloperProfile command', () => {
it('is registered as a command', () => {
const commands = useCoreCommands()
const command = commands.find(
(cmd) => cmd.id === 'Comfy.ShowDeveloperProfile'
)
expect(command).toBeDefined()
expect(command!.label).toBe('Developer Profile')
})
})
describe('Canvas clipboard commands', () => {
function findCommand(id: string) {
return useCoreCommands().find((cmd) => cmd.id === id)!

View File

@@ -65,6 +65,8 @@ import {
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
import { useDeveloperProfileDialog } from './useDeveloperProfileDialog'
import { useTemplatePublishingDialog } from './useTemplatePublishingDialog'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -337,6 +339,7 @@ export function useCoreCommands(): ComfyCommand[] {
}
},
{
// comeback
id: 'Comfy.BrowseTemplates',
icon: 'pi pi-folder-open',
label: 'Browse Templates',
@@ -344,6 +347,23 @@ export function useCoreCommands(): ComfyCommand[] {
useWorkflowTemplateSelectorDialog().show()
}
},
{
// comeback
id: 'Comfy.ShowTemplatePublishing',
icon: 'pi pi-objects-column',
label: t('templatePublishing.dialogTitle'),
function: () => {
useTemplatePublishingDialog().show()
}
},
{
id: 'Comfy.ShowDeveloperProfile',
icon: 'pi pi-user',
label: t('developerProfile.dialogTitle'),
function: () => {
useDeveloperProfileDialog().show()
}
},
{
id: 'Comfy.Canvas.ZoomIn',
icon: 'pi pi-plus',

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDialogService = vi.hoisted(() => ({
showLayoutDialog: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/components/developerProfile/DeveloperProfileDialog.vue', () => ({
default: { name: 'MockDeveloperProfileDialog' }
}))
import { useDeveloperProfileDialog } from './useDeveloperProfileDialog'
describe('useDeveloperProfileDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('show', () => {
it('opens the dialog via dialogService', () => {
const { show } = useDeveloperProfileDialog()
show()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: 'global-developer-profile'
})
)
})
it('passes username to the dialog component', () => {
const { show } = useDeveloperProfileDialog()
show('@TestUser')
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
username: '@TestUser'
})
})
)
})
it('passes undefined username when no argument given', () => {
const { show } = useDeveloperProfileDialog()
show()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
username: undefined
})
})
)
})
it('provides an onClose callback that closes the dialog', () => {
const { show } = useDeveloperProfileDialog()
show()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onClose()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-developer-profile'
})
})
})
describe('hide', () => {
it('closes the dialog via dialogStore', () => {
const { hide } = useDeveloperProfileDialog()
hide()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-developer-profile'
})
})
})
})

View File

@@ -0,0 +1,32 @@
import DeveloperProfileDialog from '@/components/developerProfile/DeveloperProfileDialog.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const DIALOG_KEY = 'global-developer-profile'
/**
* Manages the lifecycle of the developer profile dialog.
*
* @returns `show` to open the dialog for a given username, and `hide` to close it.
*/
export function useDeveloperProfileDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(username?: string) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: DeveloperProfileDialog,
props: {
onClose: hide,
username
}
})
}
return { show, hide }
}

View File

@@ -176,6 +176,30 @@ describe('useFeatureFlags', () => {
})
})
describe('templateMarketplaceEnabled', () => {
it('should return false by default', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)
const { flags } = useFeatureFlags()
expect(flags.templateMarketplaceEnabled).toBe(false)
})
it('should return true when server feature flag is enabled', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEMPLATE_MARKETPLACE_ENABLED)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.templateMarketplaceEnabled).toBe(true)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -23,7 +23,8 @@ export enum ServerFeatureFlag {
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled'
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
TEMPLATE_MARKETPLACE_ENABLED = 'template_marketplace_enabled'
}
/**
@@ -120,6 +121,13 @@ export function useFeatureFlags() {
get nodeReplacementsEnabled() {
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
},
get templateMarketplaceEnabled() {
return resolveFlag(
ServerFeatureFlag.TEMPLATE_MARKETPLACE_ENABLED,
remoteConfig.value.template_marketplace_enabled,
false
)
},
get nodeLibraryEssentialsEnabled() {
if (isNightly || import.meta.env.DEV) return true

View File

@@ -44,6 +44,15 @@ vi.mock('@/platform/telemetry', () => ({
}))
}))
const mockGraphNodes = vi.hoisted(() => ({ value: [] as { type: string }[] }))
vi.mock('@/scripts/app', () => ({
app: {
get graph() {
return { _nodes: mockGraphNodes.value }
}
}
}))
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
@@ -280,6 +289,48 @@ describe('useTemplateFiltering', () => {
])
})
it('sorts by similarity to current workflow nodes', () => {
mockGraphNodes.value = [
{ type: 'KSampler' },
{ type: 'VAEDecode' },
{ type: 'CLIPTextEncode' }
]
const templates = ref<TemplateInfo[]>([
{
name: 'unrelated',
description: 'no overlap',
mediaType: 'image',
mediaSubtype: 'png',
requiresCustomNodes: ['ThreeDLoader']
},
{
name: 'best-match',
description: 'shares two nodes',
mediaType: 'image',
mediaSubtype: 'png',
requiresCustomNodes: ['KSampler', 'VAEDecode']
},
{
name: 'partial-match',
description: 'shares one node',
mediaType: 'image',
mediaSubtype: 'png',
requiresCustomNodes: ['KSampler', 'ThreeDLoader']
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'similar-to-current'
expect(filteredTemplates.value.map((t) => t.name)).toEqual([
'best-match',
'partial-match',
'unrelated'
])
})
describe('loadFuseOptions', () => {
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
const templates = ref<TemplateInfo[]>([

View File

@@ -7,7 +7,13 @@ import type { Ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { app } from '@/scripts/app'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import type { TemplateSimilarityInput } from '@/utils/templateSimilarity'
import {
computeSimilarity,
toSimilarityInput
} from '@/utils/templateSimilarity'
import { debounce } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
@@ -50,6 +56,7 @@ export function useTemplateFiltering(
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
| 'similar-to-current'
>(settingStore.get('Comfy.Templates.SortBy'))
const fuseOptions = ref<IFuseOptions<TemplateInfo>>(defaultFuseOptions)
@@ -270,6 +277,18 @@ export function useTemplateFiltering(
if (sizeA === sizeB) return 0
return sizeA - sizeB
})
case 'similar-to-current': {
const nodes = app.graph?._nodes ?? []
const reference: TemplateSimilarityInput = {
name: '',
requiredNodes: nodes.map((n) => n.type)
}
return templates.sort((a, b) => {
const scoreA = computeSimilarity(reference, toSimilarityInput(a))
const scoreB = computeSimilarity(reference, toSimilarityInput(b))
return scoreB - scoreA
})
}
case 'default':
default:
return templates

View File

@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
MAX_GALLERY_IMAGES,
useTemplatePreviewAssets
} from './useTemplatePreviewAssets'
let blobCounter = 0
const mockCreateObjectURL = vi.fn(
() => `blob:http://localhost/mock-${++blobCounter}`
)
const mockRevokeObjectURL = vi.fn()
URL.createObjectURL = mockCreateObjectURL
URL.revokeObjectURL = mockRevokeObjectURL
function makeFile(name: string): File {
return new File(['pixels'], name, { type: 'image/png' })
}
describe('useTemplatePreviewAssets', () => {
let assets: ReturnType<typeof useTemplatePreviewAssets>
beforeEach(() => {
assets = useTemplatePreviewAssets()
assets.clearAll()
vi.clearAllMocks()
blobCounter = 0
})
describe('single-asset slots', () => {
it('setThumbnail caches the file and returns a blob URL', () => {
const file = makeFile('thumb.png')
const url = assets.setThumbnail(file)
expect(url).toMatch(/^blob:/)
expect(assets.thumbnail.value).not.toBeNull()
expect(assets.thumbnail.value!.originalName).toBe('thumb.png')
expect(assets.thumbnail.value!.file.name).toBe(file.name)
})
it('setThumbnail revokes the previous blob URL', () => {
assets.setThumbnail(makeFile('a.png'))
const firstUrl = assets.thumbnail.value!.objectUrl
assets.setThumbnail(makeFile('b.png'))
expect(mockRevokeObjectURL).toHaveBeenCalledWith(firstUrl)
})
it('clearThumbnail revokes and nullifies', () => {
assets.setThumbnail(makeFile('thumb.png'))
const url = assets.thumbnail.value!.objectUrl
assets.clearThumbnail()
expect(assets.thumbnail.value).toBeNull()
expect(mockRevokeObjectURL).toHaveBeenCalledWith(url)
})
it('clearThumbnail is safe to call when empty', () => {
expect(() => assets.clearThumbnail()).not.toThrow()
expect(assets.thumbnail.value).toBeNull()
})
it('setBeforeImage and setAfterImage operate independently', () => {
assets.setBeforeImage(makeFile('before.png'))
assets.setAfterImage(makeFile('after.png'))
expect(assets.beforeImage.value!.originalName).toBe('before.png')
expect(assets.afterImage.value!.originalName).toBe('after.png')
})
it('setVideoPreview caches a video file', () => {
const video = new File(['frames'], 'demo.mp4', { type: 'video/mp4' })
assets.setVideoPreview(video)
expect(assets.videoPreview.value!.originalName).toBe('demo.mp4')
})
it('setWorkflowPreview caches an image file', () => {
assets.setWorkflowPreview(makeFile('graph.png'))
expect(assets.workflowPreview.value!.originalName).toBe('graph.png')
})
})
describe('gallery', () => {
it('addGalleryImage appends to the gallery and returns the URL', () => {
const url = assets.addGalleryImage(makeFile('output1.png'))
expect(url).toMatch(/^blob:/)
expect(assets.galleryImages.value).toHaveLength(1)
expect(assets.galleryImages.value[0].originalName).toBe('output1.png')
})
it('addGalleryImage returns null when gallery is full', () => {
for (let i = 0; i < MAX_GALLERY_IMAGES; i++) {
assets.addGalleryImage(makeFile(`img${i}.png`))
}
const result = assets.addGalleryImage(makeFile('overflow.png'))
expect(result).toBeNull()
expect(assets.galleryImages.value).toHaveLength(MAX_GALLERY_IMAGES)
})
it('removeGalleryImage removes by index and revokes its URL', () => {
assets.addGalleryImage(makeFile('a.png'))
assets.addGalleryImage(makeFile('b.png'))
assets.addGalleryImage(makeFile('c.png'))
const removedUrl = assets.galleryImages.value[1].objectUrl
assets.removeGalleryImage(1)
expect(assets.galleryImages.value).toHaveLength(2)
expect(assets.galleryImages.value.map((a) => a.originalName)).toEqual([
'a.png',
'c.png'
])
expect(mockRevokeObjectURL).toHaveBeenCalledWith(removedUrl)
})
it('removeGalleryImage is safe with out-of-range index', () => {
assets.addGalleryImage(makeFile('a.png'))
expect(() => assets.removeGalleryImage(99)).not.toThrow()
expect(assets.galleryImages.value).toHaveLength(1)
})
})
describe('clearAll', () => {
it('revokes all blob URLs and resets all slots', () => {
assets.setThumbnail(makeFile('thumb.png'))
assets.setBeforeImage(makeFile('before.png'))
assets.setAfterImage(makeFile('after.png'))
assets.setVideoPreview(new File(['v'], 'vid.mp4', { type: 'video/mp4' }))
assets.setWorkflowPreview(makeFile('graph.png'))
assets.addGalleryImage(makeFile('g1.png'))
assets.addGalleryImage(makeFile('g2.png'))
vi.clearAllMocks()
assets.clearAll()
expect(assets.thumbnail.value).toBeNull()
expect(assets.beforeImage.value).toBeNull()
expect(assets.afterImage.value).toBeNull()
expect(assets.videoPreview.value).toBeNull()
expect(assets.workflowPreview.value).toBeNull()
expect(assets.galleryImages.value).toEqual([])
// 5 single slots + 2 gallery = 7 revocations
expect(mockRevokeObjectURL).toHaveBeenCalledTimes(7)
})
})
it('MAX_GALLERY_IMAGES is 6', () => {
expect(MAX_GALLERY_IMAGES).toBe(6)
})
})

View File

@@ -0,0 +1,121 @@
/**
* Manages in-memory cached file assets for the template publishing preview
* step. Files are held as reactive refs with `blob:` URLs for local display.
*
* State is module-level so it persists across step navigation within the
* publishing dialog but is lost on page reload.
*/
import type { Ref } from 'vue'
import { ref } from 'vue'
import type { CachedAsset } from '@/types/templateMarketplace'
/** Maximum number of images allowed in the example output gallery. */
export const MAX_GALLERY_IMAGES = 6
const thumbnail = ref<CachedAsset | null>(null)
const beforeImage = ref<CachedAsset | null>(null)
const afterImage = ref<CachedAsset | null>(null)
const videoPreview = ref<CachedAsset | null>(null)
const workflowPreview = ref<CachedAsset | null>(null)
const galleryImages = ref<CachedAsset[]>([])
/**
* Creates a {@link CachedAsset} from a File, generating a blob URL for
* local display.
*/
function cacheFile(file: File): CachedAsset {
return {
file,
objectUrl: URL.createObjectURL(file),
originalName: file.name
}
}
/**
* Replaces the value of a single-asset ref, revoking the previous blob URL
* if one existed.
*
* @returns The blob URL of the newly cached asset.
*/
function setAsset(slot: Ref<CachedAsset | null>, file: File): string {
if (slot.value) URL.revokeObjectURL(slot.value.objectUrl)
slot.value = cacheFile(file)
return slot.value.objectUrl
}
/**
* Clears a single-asset ref and revokes its blob URL.
*/
function clearAsset(slot: Ref<CachedAsset | null>): void {
if (slot.value) {
URL.revokeObjectURL(slot.value.objectUrl)
slot.value = null
}
}
/**
* Provides reactive access to all preview asset slots and methods to
* populate, clear, and query them.
*/
export function useTemplatePreviewAssets() {
/**
* Adds an image to the example output gallery.
*
* @returns The blob URL of the added image, or `null` if the gallery
* is already at {@link MAX_GALLERY_IMAGES}.
*/
function addGalleryImage(file: File): string | null {
if (galleryImages.value.length >= MAX_GALLERY_IMAGES) return null
const asset = cacheFile(file)
galleryImages.value = [...galleryImages.value, asset]
return asset.objectUrl
}
/**
* Removes a gallery image by its index and revokes its blob URL.
*/
function removeGalleryImage(index: number): void {
const asset = galleryImages.value[index]
if (!asset) return
URL.revokeObjectURL(asset.objectUrl)
galleryImages.value = galleryImages.value.filter((_, i) => i !== index)
}
/**
* Revokes all blob URLs and resets every asset slot to its empty state.
*/
function clearAll(): void {
clearAsset(thumbnail)
clearAsset(beforeImage)
clearAsset(afterImage)
clearAsset(videoPreview)
clearAsset(workflowPreview)
for (const asset of galleryImages.value) {
URL.revokeObjectURL(asset.objectUrl)
}
galleryImages.value = []
}
return {
thumbnail,
beforeImage,
afterImage,
videoPreview,
workflowPreview,
galleryImages,
setThumbnail: (file: File) => setAsset(thumbnail, file),
clearThumbnail: () => clearAsset(thumbnail),
setBeforeImage: (file: File) => setAsset(beforeImage, file),
clearBeforeImage: () => clearAsset(beforeImage),
setAfterImage: (file: File) => setAsset(afterImage, file),
clearAfterImage: () => clearAsset(afterImage),
setVideoPreview: (file: File) => setAsset(videoPreview, file),
clearVideoPreview: () => clearAsset(videoPreview),
setWorkflowPreview: (file: File) => setAsset(workflowPreview, file),
clearWorkflowPreview: () => clearAsset(workflowPreview),
addGalleryImage,
removeGalleryImage,
clearAll
}
}

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDialogService = vi.hoisted(() => ({
showLayoutDialog: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/components/templatePublishing/TemplatePublishingDialog.vue', () => ({
default: { name: 'MockTemplatePublishingDialog' }
}))
import { useTemplatePublishingDialog } from './useTemplatePublishingDialog'
describe('useTemplatePublishingDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('show', () => {
it('opens the dialog via dialogService', () => {
const { show } = useTemplatePublishingDialog()
show()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: 'global-template-publishing'
})
)
})
it('passes initialPage to the dialog component', () => {
const { show } = useTemplatePublishingDialog()
show({ initialPage: 'metadata' })
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
initialPage: 'metadata'
})
})
)
})
it('passes undefined initialPage when no options given', () => {
const { show } = useTemplatePublishingDialog()
show()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
initialPage: undefined
})
})
)
})
it('provides an onClose callback that closes the dialog', () => {
const { show } = useTemplatePublishingDialog()
show()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onClose()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-template-publishing'
})
})
})
describe('hide', () => {
it('closes the dialog via dialogStore', () => {
const { hide } = useTemplatePublishingDialog()
hide()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-template-publishing'
})
})
})
})

View File

@@ -0,0 +1,38 @@
import TemplatePublishingDialog from '@/components/templatePublishing/TemplatePublishingDialog.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const DIALOG_KEY = 'global-template-publishing'
/**
* Manages the lifecycle of the template publishing dialog.
*
* @returns `show` to open the dialog and `hide` to close it.
*/
export const useTemplatePublishingDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(options?: { initialPage?: string }) {
// comeback need a new telemetry for this
// useTelemetry()?.trackTemplatePublishingOpened({ source })
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: TemplatePublishingDialog,
props: {
onClose: hide,
initialPage: options?.initialPage
}
})
}
return {
show,
hide
}
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest'
const { mockLoad, mockSave } = vi.hoisted(() => ({
mockLoad: vi.fn(),
mockSave: vi.fn()
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplatePublishStorage',
() => ({
loadTemplateUnderway: mockLoad,
saveTemplateUnderway: mockSave
})
)
import { useTemplatePublishingStepper } from './useTemplatePublishingStepper'
describe('useTemplatePublishingStepper', () => {
it('starts at step 1 by default', () => {
const { currentStep } = useTemplatePublishingStepper()
expect(currentStep.value).toBe(1)
})
it('starts at the given initialStep', () => {
const { currentStep } = useTemplatePublishingStepper({ initialStep: 4 })
expect(currentStep.value).toBe(4)
})
it('clamps initialStep to valid range', () => {
const low = useTemplatePublishingStepper({ initialStep: 0 })
expect(low.currentStep.value).toBe(1)
const high = useTemplatePublishingStepper({ initialStep: 99 })
expect(high.currentStep.value).toBe(high.totalSteps)
})
it('nextStep advances by one', () => {
const { currentStep, nextStep } = useTemplatePublishingStepper()
nextStep()
expect(currentStep.value).toBe(2)
})
it('nextStep does not exceed totalSteps', () => {
const { currentStep, nextStep, totalSteps } = useTemplatePublishingStepper({
initialStep: 8
})
nextStep()
expect(currentStep.value).toBe(totalSteps)
})
it('prevStep goes back by one', () => {
const { currentStep, prevStep } = useTemplatePublishingStepper({
initialStep: 3
})
prevStep()
expect(currentStep.value).toBe(2)
})
it('prevStep does not go below 1', () => {
const { currentStep, prevStep } = useTemplatePublishingStepper()
prevStep()
expect(currentStep.value).toBe(1)
})
it('goToStep navigates to the given step', () => {
const { currentStep, goToStep } = useTemplatePublishingStepper()
goToStep(5)
expect(currentStep.value).toBe(5)
})
it('goToStep clamps out-of-range values', () => {
const { currentStep, goToStep, totalSteps } = useTemplatePublishingStepper()
goToStep(100)
expect(currentStep.value).toBe(totalSteps)
goToStep(-1)
expect(currentStep.value).toBe(1)
})
it('isFirstStep and isLastStep reflect current position', () => {
const { isFirstStep, isLastStep, nextStep, goToStep, totalSteps } =
useTemplatePublishingStepper()
expect(isFirstStep.value).toBe(true)
expect(isLastStep.value).toBe(false)
nextStep()
expect(isFirstStep.value).toBe(false)
goToStep(totalSteps)
expect(isLastStep.value).toBe(true)
})
it('canProceed reflects step validity', () => {
const { canProceed, setStepValid } = useTemplatePublishingStepper()
expect(canProceed.value).toBe(false)
setStepValid(1, true)
expect(canProceed.value).toBe(true)
setStepValid(1, false)
expect(canProceed.value).toBe(false)
})
it('saveDraft delegates to saveTemplateUnderway', () => {
const { template, saveDraft } = useTemplatePublishingStepper()
template.value = { title: 'Test Template' }
saveDraft()
expect(mockSave).toHaveBeenCalledWith({ title: 'Test Template' })
})
it('loads existing draft on initialisation', () => {
const draft = { title: 'Saved Draft', description: 'A draft' }
mockLoad.mockReturnValueOnce(draft)
const { template } = useTemplatePublishingStepper()
expect(template.value).toEqual(draft)
})
it('uses empty object when no draft is stored', () => {
mockLoad.mockReturnValueOnce(null)
const { template } = useTemplatePublishingStepper()
expect(template.value).toEqual({})
})
it('exposes the correct number of step definitions', () => {
const { stepDefinitions, totalSteps } = useTemplatePublishingStepper()
expect(stepDefinitions).toHaveLength(totalSteps)
expect(totalSteps).toBe(8)
})
})

View File

@@ -0,0 +1,85 @@
import { computed, ref } from 'vue'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import type { PublishingStepDefinition } from '@/components/templatePublishing/types'
import { PUBLISHING_STEP_DEFINITIONS } from '@/components/templatePublishing/types'
import {
loadTemplateUnderway,
saveTemplateUnderway
} from '@/platform/workflow/templates/composables/useTemplatePublishStorage'
/**
* Manages the state and navigation logic for the template publishing
* wizard.
*
* Owns the current step, per-step validity tracking, and draft
* persistence via {@link saveTemplateUnderway}/{@link loadTemplateUnderway}.
*
* @param options.initialStep - 1-indexed step to start on (defaults to 1)
*/
export function useTemplatePublishingStepper(options?: {
initialStep?: number
}) {
const totalSteps = PUBLISHING_STEP_DEFINITIONS.length
const currentStep = ref(clampStep(options?.initialStep ?? 1, totalSteps))
const template = ref<Partial<MarketplaceTemplate>>(
loadTemplateUnderway() ?? {}
)
const stepValidity = ref<Record<number, boolean>>({})
const stepDefinitions: PublishingStepDefinition[] =
PUBLISHING_STEP_DEFINITIONS
const isFirstStep = computed(() => currentStep.value === 1)
const isLastStep = computed(() => currentStep.value === totalSteps)
const canProceed = computed(
() => stepValidity.value[currentStep.value] === true
)
function goToStep(step: number) {
currentStep.value = clampStep(step, totalSteps)
}
function nextStep() {
if (!isLastStep.value) {
currentStep.value = clampStep(currentStep.value + 1, totalSteps)
}
}
function prevStep() {
if (!isFirstStep.value) {
currentStep.value = clampStep(currentStep.value - 1, totalSteps)
}
}
function saveDraft() {
saveTemplateUnderway(template.value)
}
function setStepValid(step: number, valid: boolean) {
stepValidity.value[step] = valid
}
return {
currentStep,
totalSteps,
template,
stepDefinitions,
isFirstStep,
isLastStep,
canProceed,
goToStep,
nextStep,
prevStep,
saveDraft,
setStepValid
}
}
function clampStep(step: number, max: number): number {
return Math.max(1, Math.min(step, max))
}

View File

@@ -0,0 +1,220 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
detectModelNodes,
estimateWorkflowVram,
MODEL_VRAM_ESTIMATES,
RUNTIME_OVERHEAD
} from './useVramEstimation'
const mockGetCategoryForNodeType = vi.fn<(type: string) => string | undefined>()
const mockGetAllNodeProviders = vi.fn()
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType,
getAllNodeProviders: mockGetAllNodeProviders
})
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
mapAllNodes: vi.fn(
(
graph: { nodes: Array<Record<string, unknown>> },
mapFn: (node: Record<string, unknown>) => unknown
) => graph.nodes.map(mapFn).filter((r) => r !== undefined)
)
}))
function makeNode(
type: string,
widgets: Array<{ name: string; value: unknown }> = []
) {
return {
type,
isSubgraphNode: () => false,
widgets
}
}
function makeGraph(nodes: ReturnType<typeof makeNode>[]) {
return { nodes } as never
}
describe('useVramEstimation', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockGetCategoryForNodeType.mockReset()
mockGetAllNodeProviders.mockReset()
mockGetAllNodeProviders.mockReturnValue([])
})
describe('detectModelNodes', () => {
it('returns empty array for graph with no model nodes', () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
const graph = makeGraph([makeNode('KSampler'), makeNode('SaveImage')])
expect(detectModelNodes(graph)).toEqual([])
})
it('detects checkpoint loader nodes', () => {
mockGetCategoryForNodeType.mockImplementation((type: string) =>
type === 'CheckpointLoaderSimple' ? 'checkpoints' : undefined
)
const graph = makeGraph([
makeNode('CheckpointLoaderSimple'),
makeNode('KSampler')
])
const result = detectModelNodes(graph)
expect(result).toHaveLength(1)
expect(result[0].category).toBe('checkpoints')
})
it('deduplicates models with same category and filename', () => {
mockGetCategoryForNodeType.mockImplementation((type: string) =>
type === 'CheckpointLoaderSimple' ? 'checkpoints' : undefined
)
mockGetAllNodeProviders.mockReturnValue([
{
nodeDef: { name: 'CheckpointLoaderSimple' },
key: 'ckpt_name'
}
])
const graph = makeGraph([
makeNode('CheckpointLoaderSimple', [
{ name: 'ckpt_name', value: 'model.safetensors' }
]),
makeNode('CheckpointLoaderSimple', [
{ name: 'ckpt_name', value: 'model.safetensors' }
])
])
expect(detectModelNodes(graph)).toHaveLength(1)
})
it('keeps models with same category but different filenames', () => {
mockGetCategoryForNodeType.mockImplementation((type: string) =>
type === 'LoraLoader' ? 'loras' : undefined
)
mockGetAllNodeProviders.mockReturnValue([
{ nodeDef: { name: 'LoraLoader' }, key: 'lora_name' }
])
const graph = makeGraph([
makeNode('LoraLoader', [
{ name: 'lora_name', value: 'lora_a.safetensors' }
]),
makeNode('LoraLoader', [
{ name: 'lora_name', value: 'lora_b.safetensors' }
])
])
expect(detectModelNodes(graph)).toHaveLength(2)
})
})
describe('estimateWorkflowVram', () => {
it('returns 0 for null/undefined graph', () => {
expect(estimateWorkflowVram(null)).toBe(0)
expect(estimateWorkflowVram(undefined)).toBe(0)
})
it('returns 0 for graph with no model nodes', () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
expect(estimateWorkflowVram(makeGraph([makeNode('KSampler')]))).toBe(0)
})
it('estimates checkpoint-only workflow as base + overhead', () => {
mockGetCategoryForNodeType.mockImplementation((type: string) =>
type === 'CheckpointLoaderSimple' ? 'checkpoints' : undefined
)
const result = estimateWorkflowVram(
makeGraph([makeNode('CheckpointLoaderSimple'), makeNode('KSampler')])
)
expect(result).toBe(MODEL_VRAM_ESTIMATES.checkpoints + RUNTIME_OVERHEAD)
})
it('uses only the largest base model when multiple checkpoints exist', () => {
mockGetCategoryForNodeType.mockImplementation((type: string) => {
if (type === 'CheckpointLoaderSimple') return 'checkpoints'
if (type === 'UNETLoader') return 'diffusion_models'
return undefined
})
const result = estimateWorkflowVram(
makeGraph([makeNode('CheckpointLoaderSimple'), makeNode('UNETLoader')])
)
const largestBase = Math.max(
MODEL_VRAM_ESTIMATES.checkpoints,
MODEL_VRAM_ESTIMATES.diffusion_models
)
expect(result).toBe(largestBase + RUNTIME_OVERHEAD)
})
it('sums checkpoint + lora + controlnet correctly', () => {
mockGetCategoryForNodeType.mockImplementation((type: string) => {
const map: Record<string, string> = {
CheckpointLoaderSimple: 'checkpoints',
LoraLoader: 'loras',
ControlNetLoader: 'controlnet'
}
return map[type]
})
const result = estimateWorkflowVram(
makeGraph([
makeNode('CheckpointLoaderSimple'),
makeNode('LoraLoader'),
makeNode('ControlNetLoader')
])
)
expect(result).toBe(
MODEL_VRAM_ESTIMATES.checkpoints +
MODEL_VRAM_ESTIMATES.loras +
MODEL_VRAM_ESTIMATES.controlnet +
RUNTIME_OVERHEAD
)
})
it('handles unknown model categories with default estimate', () => {
mockGetCategoryForNodeType.mockReturnValue('some_unknown_category')
const result = estimateWorkflowVram(
makeGraph([makeNode('UnknownModelLoader')])
)
// Unknown category uses 500 MB default + runtime overhead
expect(result).toBe(500_000_000 + RUNTIME_OVERHEAD)
})
it('counts multiple unique loras separately', () => {
mockGetCategoryForNodeType.mockImplementation((type: string) =>
type === 'LoraLoader' ? 'loras' : undefined
)
mockGetAllNodeProviders.mockReturnValue([
{ nodeDef: { name: 'LoraLoader' }, key: 'lora_name' }
])
const result = estimateWorkflowVram(
makeGraph([
makeNode('LoraLoader', [
{ name: 'lora_name', value: 'lora_a.safetensors' }
]),
makeNode('LoraLoader', [
{ name: 'lora_name', value: 'lora_b.safetensors' }
])
])
)
expect(result).toBe(MODEL_VRAM_ESTIMATES.loras * 2 + RUNTIME_OVERHEAD)
})
})
})

View File

@@ -0,0 +1,139 @@
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
/**
* A model detected in a workflow graph, identified by the directory
* category it belongs to and the filename selected in its widget.
*/
export interface DetectedModel {
/** Model directory category (e.g. 'checkpoints', 'loras'). */
category: string
/** Selected model filename from the node's widget, if available. */
filename: string | undefined
}
/**
* Approximate VRAM consumption in bytes per model directory category.
* Values represent typical fp16 model sizes loaded into GPU memory.
*/
export const MODEL_VRAM_ESTIMATES: Record<string, number> = {
checkpoints: 4_500_000_000,
diffusion_models: 4_500_000_000,
loras: 200_000_000,
controlnet: 1_500_000_000,
vae: 350_000_000,
clip_vision: 600_000_000,
text_encoders: 1_200_000_000,
upscale_models: 200_000_000,
style_models: 500_000_000,
gligen: 500_000_000
}
/** Default VRAM estimate for unrecognised model categories. */
const DEFAULT_MODEL_VRAM = 500_000_000
/** Flat overhead for intermediate tensors and activations. */
export const RUNTIME_OVERHEAD = 500_000_000
/**
* Categories whose models act as the "base" diffusion backbone.
* Only the single largest base model is counted because ComfyUI
* does not keep multiple base models resident simultaneously.
*/
const BASE_MODEL_CATEGORIES = new Set(['checkpoints', 'diffusion_models'])
/**
* Extracts the widget value for the model input key from a graph node.
*
* @param node - The graph node to inspect
* @param category - The model category, used to look up the expected input key
* @returns The string widget value, or undefined if not found
*/
function getModelWidgetValue(
node: LGraphNode,
category: string
): string | undefined {
const store = useModelToNodeStore()
const providers = store.getAllNodeProviders(category)
for (const provider of providers) {
if (provider.nodeDef?.name !== node.type) continue
if (!provider.key) return undefined
const widget = node.widgets?.find((w) => w.name === provider.key)
if (widget?.value && typeof widget.value === 'string') {
return widget.value
}
}
return undefined
}
/**
* Detects all model-loading nodes in a graph hierarchy and returns
* a deduplicated list of models with their category and filename.
*
* @param graph - The root graph (or subgraph) to traverse
* @returns Array of unique detected models
*/
export function detectModelNodes(graph: LGraph | Subgraph): DetectedModel[] {
const store = useModelToNodeStore()
const raw = mapAllNodes(graph, (node) => {
if (!node.type) return undefined
const category = store.getCategoryForNodeType(node.type)
if (!category) return undefined
const filename = getModelWidgetValue(node, category)
return { category, filename } satisfies DetectedModel
})
const seen = new Set<string>()
return raw.filter((model) => {
const key = `${model.category}::${model.filename ?? ''}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
/**
* Estimates peak VRAM consumption (in bytes) for a workflow graph.
*
* The heuristic:
* 1. Detect all model-loading nodes in the graph.
* 2. For base model categories (checkpoints, diffusion_models), take only
* the largest single model — ComfyUI offloads others.
* 3. Sum all other model categories (LoRAs, ControlNets, VAEs, etc.)
* as they can be co-resident.
* 4. Add a flat runtime overhead for activations and intermediates.
*
* @param graph - The root graph to analyse
* @returns Estimated VRAM in bytes, or 0 if no models detected
*/
export function estimateWorkflowVram(
graph: LGraph | Subgraph | null | undefined
): number {
if (!graph) return 0
const models = detectModelNodes(graph)
if (models.length === 0) return 0
let baseCost = 0
let additionalCost = 0
for (const model of models) {
const estimate = MODEL_VRAM_ESTIMATES[model.category] ?? DEFAULT_MODEL_VRAM
if (BASE_MODEL_CATEGORIES.has(model.category)) {
baseCost = Math.max(baseCost, estimate)
} else {
additionalCost += estimate
}
}
return baseCost + additionalCost + RUNTIME_OVERHEAD
}

View File

@@ -45,7 +45,7 @@ const mockCanvasStore = vi.hoisted(() => ({
}))
const mockFeatureFlags = vi.hoisted(() => ({
flags: { linearToggleEnabled: false }
flags: { linearToggleEnabled: false, templateMarketplaceEnabled: false }
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
@@ -102,6 +102,7 @@ describe('useWorkflowActionsMenu', () => {
mockMenuItemStore.hasSeenLinear = false
mockCanvasStore.linearMode = false
mockFeatureFlags.flags.linearToggleEnabled = false
mockFeatureFlags.flags.templateMarketplaceEnabled = false
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true
@@ -312,6 +313,33 @@ describe('useWorkflowActionsMenu', () => {
expect(rename.disabled).toBe(true)
})
it('shows developer profile when templateMarketplaceEnabled flag is set', () => {
mockFeatureFlags.flags.templateMarketplaceEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('menuLabels.developerProfile')
})
it('hides developer profile when templateMarketplaceEnabled flag is not set', () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('menuLabels.developerProfile')
})
it('developer profile executes Comfy.ShowDeveloperProfile', async () => {
mockFeatureFlags.flags.templateMarketplaceEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(menuItems.value, 'menuLabels.developerProfile').command?.()
expect(mockCommandStore.execute).toHaveBeenCalledWith(
'Comfy.ShowDeveloperProfile'
)
})
it('bookmark is disabled for temporary workflows', () => {
mockWorkflowStore.activeWorkflow = {
path: 'test.json',

View File

@@ -197,6 +197,25 @@ export function useWorkflowActionsMenu(
prependSeparator: true
})
addItem({
label: t('menuLabels.templatePublishing'),
icon: 'pi pi-objects-column',
command: async () => {
await commandStore.execute('Comfy.ShowTemplatePublishing')
},
visible: isRoot && flags.templateMarketplaceEnabled,
prependSeparator: true
})
addItem({
label: t('menuLabels.developerProfile'),
icon: 'pi pi-user',
command: async () => {
await commandStore.execute('Comfy.ShowDeveloperProfile')
},
visible: isRoot && flags.templateMarketplaceEnabled
})
addItem({
label: t('subgraphStore.publish'),
icon: 'pi pi-upload',

View File

@@ -1004,8 +1004,10 @@
"searchPlaceholder": "Search...",
"vramLowToHigh": "VRAM Usage (Low to High)",
"modelSizeLowToHigh": "Model Size (Low to High)",
"default": "Default"
"default": "Default",
"similarToCurrent": "Similar to Current"
},
"vramEstimateTooltip": "Estimated GPU memory required to run this workflow",
"error": {
"templateNotFound": "Template \"{templateName}\" not found"
}
@@ -1054,6 +1056,155 @@
"enterFilenamePrompt": "Enter the filename:",
"saveWorkflow": "Save workflow"
},
"templatePublishing": {
"publishToMarketplace": "Publish to Marketplace",
"saveDraft": "Save Draft",
"previewThenSave": "Preview then Save",
"dialogTitle": "Template Publishing",
"next": "Next",
"previous": "Previous",
"submit": "Submit for Review",
"stepProgress": "Step {current} of {total}",
"steps": {
"landing": {
"title": "Getting Started",
"description": "Overview of the publishing process"
},
"metadata": {
"title": "Metadata",
"description": "Title, description, and author info",
"titleLabel": "Title",
"categoryLabel": "Categories",
"tagsLabel": "Tags",
"tagsPlaceholder": "Type to search tags…",
"difficultyLabel": "Difficulty",
"licenseLabel": "License",
"requiredNodesLabel": "Custom Nodes",
"requiredNodesDetected": "Detected from workflow",
"requiredNodesManualPlaceholder": "Add custom node name…",
"requiredNodesManualLabel": "Additional custom nodes",
"vramLabel": "Estimated VRAM Requirement",
"vramAutoDetected": "Auto-detected from workflow:",
"vramManualOverride": "Manual override (GB):",
"difficulty": {
"beginner": "Beginner",
"intermediate": "Intermediate",
"advanced": "Advanced"
},
"license": {
"mit": "MIT",
"ccBy": "CC BY",
"ccBySa": "CC BY-SA",
"ccByNc": "CC BY-NC",
"apache": "Apache",
"custom": "Custom"
},
"category": {
"imageGeneration": "Image Generation",
"videoGeneration": "Video Generation",
"audio": "Audio",
"text": "Text",
"threeD": "3D",
"upscaling": "Upscaling",
"inpainting": "Inpainting",
"controlNet": "ControlNet",
"styleTransfer": "Style Transfer",
"other": "Other"
}
},
"description": {
"title": "Description",
"description": "Write a detailed description of your template",
"editorLabel": "Description (Markdown)",
"previewLabel": "Description (Render preview)"
},
"previewGeneration": {
"title": "Preview",
"description": "Generate preview images and videos",
"thumbnailLabel": "Thumbnail",
"thumbnailHint": "Primary image shown in marketplace listings",
"comparisonLabel": "Before & After Comparison",
"comparisonHint": "Show what the workflow transforms",
"beforeImageLabel": "Before",
"afterImageLabel": "After",
"workflowPreviewLabel": "Workflow Graph",
"workflowPreviewHint": "Screenshot of the workflow graph layout",
"videoPreviewLabel": "Video Preview",
"videoPreviewHint": "Optional short video demonstrating the workflow",
"galleryLabel": "Example Gallery",
"galleryHint": "Up to {max} example output images",
"uploadPrompt": "Click to upload",
"removeFile": "Remove",
"uploadingProgress": "Uploading… {percent}%"
},
"categoryAndTagging": {
"title": "Categories & Tags",
"description": "Categorize and tag your template"
},
"preview": {
"title": "Preview",
"description": "Review your template before submitting",
"sectionMetadata": "Metadata",
"sectionDescription": "Description",
"sectionPreviewAssets": "Preview Assets",
"sectionCategoriesAndTags": "Categories & Tags",
"thumbnailLabel": "Thumbnail",
"comparisonLabel": "Before & After",
"workflowPreviewLabel": "Workflow Graph",
"videoPreviewLabel": "Video Preview",
"galleryLabel": "Gallery",
"vramLabel": "VRAM Requirement",
"notProvided": "Not provided",
"noneDetected": "None detected",
"correct": "Correct",
"editStep": "Edit"
},
"submissionForReview": {
"title": "Submit",
"description": "Submit your template for review. Comfy staff will evaluate the template, and either publish it or contact you here with next steps."
},
"complete": {
"title": "Complete",
"description": "Your template has been submitted"
}
}
},
"developerProfile": {
"dialogTitle": "Developer Profile",
"username": "Username",
"bio": "Bio",
"reviews": "Reviews",
"publishedTemplates": "Published Templates",
"dependencies": "Dependencies",
"totalDownloads": "Downloads",
"totalFavorites": "Favorites",
"averageRating": "Avg. Rating",
"templateCount": "Templates",
"revenue": "Revenue",
"monthlyRevenue": "Monthly",
"totalRevenue": "Total",
"noReviews": "No reviews yet",
"noTemplates": "No published templates yet",
"unpublish": "Unpublish",
"save": "Save Profile",
"saving": "Saving...",
"verified": "Verified",
"quickActions": "Quick Actions",
"bannerPlaceholder": "Banner image",
"editUsername": "Edit username",
"editBio": "Edit bio",
"lookupHandle": "Enter developer handle\u2026",
"downloads": "Downloads",
"favorites": "Favorites",
"rating": "Rating",
"downloadHistory": "Download History",
"range": {
"week": "Week",
"month": "Month",
"year": "Year",
"allTime": "All Time"
}
},
"subgraphStore": {
"confirmDeleteTitle": "Delete blueprint?",
"confirmDelete": "This action will permanently remove the blueprint from your library",
@@ -1327,7 +1478,9 @@
"Assets": "Assets",
"Model Library": "Model Library",
"Node Library": "Node Library",
"Workflows": "Workflows"
"Workflows": "Workflows",
"templatePublishing": "Template Publishing",
"developerProfile": "Developer Profile"
},
"desktopMenu": {
"reinstall": "Reinstall",

View File

@@ -44,4 +44,5 @@ export type RemoteConfig = {
team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
template_marketplace_enabled?: boolean
}

View File

@@ -222,6 +222,7 @@ export interface TemplateFilterMetadata {
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
| 'similar-to-current'
filtered_count: number
total_count: number
}

View File

@@ -0,0 +1,162 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
loadTemplateUnderway,
saveTemplateUnderway
} from '@/platform/workflow/templates/composables/useTemplatePublishStorage'
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
const STORAGE_KEY = 'Comfy.TemplateMarketplace.TemplateUnderway'
const storageMocks = vi.hoisted(() => ({
getStorageValue: vi.fn(),
setStorageValue: vi.fn()
}))
vi.mock('@/scripts/utils', () => storageMocks)
describe('useTemplatePublishStorage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const template: Partial<MarketplaceTemplate> = {
title: 'My Template',
description: 'A test template',
difficulty: 'beginner',
tags: ['test']
}
describe('saveTemplateUnderway', () => {
it('serialises the template and writes it to storage', () => {
saveTemplateUnderway(template)
expect(storageMocks.setStorageValue).toHaveBeenCalledWith(
STORAGE_KEY,
JSON.stringify(template)
)
})
it('throws TypeError when the template cannot be serialised', () => {
const circular = { title: 'oops' } as Partial<MarketplaceTemplate>
;(circular as Record<string, unknown>).self = circular
expect(() => saveTemplateUnderway(circular)).toThrow(TypeError)
expect(storageMocks.setStorageValue).not.toHaveBeenCalled()
})
})
describe('loadTemplateUnderway', () => {
it('returns the parsed template when storage contains valid JSON', () => {
storageMocks.getStorageValue.mockReturnValue(JSON.stringify(template))
expect(loadTemplateUnderway()).toEqual(template)
expect(storageMocks.getStorageValue).toHaveBeenCalledWith(STORAGE_KEY)
})
it('returns null when no value is stored', () => {
storageMocks.getStorageValue.mockReturnValue(null)
expect(loadTemplateUnderway()).toBeNull()
})
it('returns null when stored value is invalid JSON', () => {
storageMocks.getStorageValue.mockReturnValue('not json{{{')
expect(loadTemplateUnderway()).toBeNull()
})
})
describe('round-trip', () => {
beforeEach(() => {
let stored: string | null = null
storageMocks.setStorageValue.mockImplementation(
(_key: string, value: string) => {
stored = value
}
)
storageMocks.getStorageValue.mockImplementation(() => stored)
})
it.each<{
label: string
input: Partial<MarketplaceTemplate>
}>([
{
label: 'string values',
input: { title: 'hello', description: '' }
},
{
label: 'number values',
input: { vramRequirement: 0 }
},
{
label: 'negative and fractional numbers',
input: { vramRequirement: -1.5 }
},
{
label: 'boolean values',
input: {
author: { id: '1', name: 'a', isVerified: false, profileUrl: '' }
}
},
{
label: 'null values',
input: { videoPreview: undefined, reviewFeedback: undefined }
},
{
label: 'array values',
input: { tags: ['a', 'b'], categories: [], requiredNodes: ['node1'] }
},
{
label: 'nested objects',
input: {
author: {
id: '1',
name: 'Author',
isVerified: true,
profileUrl: '/u/1'
},
stats: {
downloads: 42,
favorites: 7,
rating: 4.5,
reviewCount: 3,
weeklyTrend: -2.1
}
}
},
{
label: 'mixed types in a single template',
input: {
id: '123',
title: 'Full',
description: 'A template with all JSON types',
tags: ['mixed'],
vramRequirement: 8_000_000_000,
author: { id: '1', name: 'Test', isVerified: true, profileUrl: '' },
gallery: [
{
type: 'image',
url: 'https://example.com/img.png',
isBefore: true
}
],
requiredModels: [{ name: 'model', type: 'checkpoint', size: 0 }],
stats: {
downloads: 0,
favorites: 0,
rating: 0,
reviewCount: 0,
weeklyTrend: 0
}
}
}
])('preserves $label through save/load', ({ input }) => {
saveTemplateUnderway(input)
const result = loadTemplateUnderway()
expect(result).toEqual(structuredClone(input))
})
})
})

View File

@@ -0,0 +1,57 @@
/**
* Persists and retrieves the in-progress template being published to the
* marketplace.
*
* Uses {@link setStorageValue} / {@link getStorageValue} so the draft
* survives page reloads (localStorage) while also being per-tab aware
* (sessionStorage scoped by clientId).
*/
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
const STORAGE_KEY = 'Comfy.TemplateMarketplace.TemplateUnderway'
/**
* Saves a template that is in the process of being published.
*
* The template is JSON-serialised and written to both localStorage and
* the per-tab sessionStorage slot via {@link setStorageValue}.
*
* @param template - The current state of the template being published.
* @throws {TypeError} If the template cannot be serialised to JSON
* (e.g. circular references or BigInt values).
*/
export function saveTemplateUnderway(
template: Partial<MarketplaceTemplate>
): void {
let json: string
try {
json = JSON.stringify(template)
} catch (error) {
throw new TypeError(
`Template cannot be serialised to JSON: ${error instanceof Error ? error.message : String(error)}`
)
}
setStorageValue(STORAGE_KEY, json)
}
/**
* Retrieves the locally stored in-progress template, if one exists.
*
* Reads from the per-tab sessionStorage first, falling back to
* localStorage, via {@link getStorageValue}.
*
* @returns The partially completed template, or `null` when no draft
* is stored or the stored value cannot be parsed.
*/
export function loadTemplateUnderway(): Partial<MarketplaceTemplate> | null {
const raw = getStorageValue(STORAGE_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as Partial<MarketplaceTemplate>
} catch {
return null
}
}

View File

@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
import { createTemplateScreenshot } from './templateScreenshotRenderer'
vi.mock('@/renderer/extensions/minimap/minimapCanvasRenderer', () => ({
renderMinimapToCanvas: vi.fn()
}))
const fakeBlob = new Blob(['fake'], { type: 'image/png' })
function stubCanvas() {
const clearRect = vi.fn()
const ctx = { clearRect } as unknown as CanvasRenderingContext2D
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
ctx as never
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
function (this: HTMLCanvasElement, cb, _type?, _quality?) {
cb(fakeBlob)
}
)
return { ctx, clearRect }
}
function makeGraph(nodeCount: number): LGraph {
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
pos: [i * 300, i * 200] as [number, number],
size: [200, 100] as [number, number]
}))
return { _nodes: nodes } as unknown as LGraph
}
describe('createTemplateScreenshot', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
})
it('returns null for an empty graph', async () => {
const graph = { _nodes: [] } as unknown as LGraph
const result = await createTemplateScreenshot(graph)
expect(result).toBeNull()
})
it('returns null when _nodes is undefined', async () => {
const graph = {} as unknown as LGraph
const result = await createTemplateScreenshot(graph)
expect(result).toBeNull()
})
it('renders a graph and returns a Blob', async () => {
stubCanvas()
const graph = makeGraph(3)
const blob = await createTemplateScreenshot(graph)
expect(renderMinimapToCanvas).toHaveBeenCalledOnce()
expect(blob).toBe(fakeBlob)
})
it('uses default 1920x1080 dimensions', async () => {
stubCanvas()
const graph = makeGraph(2)
await createTemplateScreenshot(graph)
const call = vi.mocked(renderMinimapToCanvas).mock.calls[0]
const context = call[2]
expect(context.width).toBe(1920)
expect(context.height).toBe(1080)
})
it('respects custom dimensions', async () => {
stubCanvas()
const graph = makeGraph(2)
await createTemplateScreenshot(graph, { width: 800, height: 600 })
const context = vi.mocked(renderMinimapToCanvas).mock.calls[0][2]
expect(context.width).toBe(800)
expect(context.height).toBe(600)
})
it('enables groups, links, and node colors by default', async () => {
stubCanvas()
const graph = makeGraph(2)
await createTemplateScreenshot(graph)
const { settings } = vi.mocked(renderMinimapToCanvas).mock.calls[0][2]
expect(settings.showGroups).toBe(true)
expect(settings.showLinks).toBe(true)
expect(settings.nodeColors).toBe(true)
})
it('passes showGroups and showLinks overrides', async () => {
stubCanvas()
const graph = makeGraph(2)
await createTemplateScreenshot(graph, {
showGroups: false,
showLinks: false,
nodeColors: false
})
const { settings } = vi.mocked(renderMinimapToCanvas).mock.calls[0][2]
expect(settings.showGroups).toBe(false)
expect(settings.showLinks).toBe(false)
expect(settings.nodeColors).toBe(false)
})
it('cleans up canvas after rendering', async () => {
const { clearRect } = stubCanvas()
const graph = makeGraph(2)
await createTemplateScreenshot(graph)
expect(clearRect).toHaveBeenCalledWith(0, 0, 1920, 1080)
})
it('returns null when toBlob yields null', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
clearRect: vi.fn()
} as never)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
function (this: HTMLCanvasElement, cb) {
cb(null)
}
)
const graph = makeGraph(2)
const result = await createTemplateScreenshot(graph)
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,97 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
calculateNodeBounds
} from '@/renderer/core/spatial/boundsCalculator'
import { renderMinimapToCanvas } from '../../extensions/minimap/minimapCanvasRenderer'
/** Configuration options for {@link createTemplateScreenshot}. */
export interface TemplateScreenshotOptions {
/** Output image width in pixels. @defaultValue 1920 */
width?: number
/** Output image height in pixels. @defaultValue 1080 */
height?: number
/** Render group rectangles behind nodes. @defaultValue true */
showGroups?: boolean
/** Render connection lines between nodes. @defaultValue true */
showLinks?: boolean
/** Use per-node background colours. @defaultValue true */
nodeColors?: boolean
/** Image MIME type passed to `canvas.toBlob`. @defaultValue 'image/png' */
mimeType?: string
/** Image quality (0-1) for lossy formats like `image/jpeg`. */
quality?: number
}
const DEFAULT_WIDTH = 1920
const DEFAULT_HEIGHT = 1080
/**
* Renders a high-resolution screenshot of a workflow graph suitable for
* template marketplace previews.
*
* Uses the minimap renderer to draw a simplified but complete overview of
* every node, group, and connection onto an off-screen canvas, then
* converts the result to a {@link Blob}.
*
* @param graph - The LiteGraph instance to capture.
* @param options - Optional rendering and output settings.
* @returns A `Blob` containing the rendered image, or `null` when the
* graph is empty or canvas creation fails.
*/
export function createTemplateScreenshot(
graph: LGraph,
options: TemplateScreenshotOptions = {}
): Promise<Blob | null> {
const {
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
showGroups = true,
showLinks = true,
nodeColors = true,
mimeType = 'image/png',
quality
} = options
if (!graph._nodes || graph._nodes.length === 0) {
return Promise.resolve(null)
}
const bounds = calculateNodeBounds(graph._nodes)
if (!bounds) {
return Promise.resolve(null)
}
const scale = calculateMinimapScale(bounds, width, height)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
renderMinimapToCanvas(canvas, graph, {
bounds,
scale,
settings: {
nodeColors,
showLinks,
showGroups,
renderBypass: true,
renderError: true
},
width,
height
})
return new Promise<Blob | null>((resolve) => {
canvas.toBlob(
(blob) => {
const ctx = canvas.getContext('2d')
if (ctx) ctx.clearRect(0, 0, width, height)
resolve(blob)
},
mimeType,
quality
)
})
}

View File

@@ -450,7 +450,8 @@ const zSettings = z.object({
'alphabetical',
'newest',
'vram-low-to-high',
'model-size-low-to-high'
'model-size-low-to-high',
'similar-to-current'
]),
/** Settings used for testing */
'test.setting': z.any(),

View File

@@ -69,6 +69,7 @@ The following table lists ALL services in the system as of 2025-09-01:
| comfyManagerService.ts | Manages ComfyUI application packages and updates | Manager |
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions | Registry |
| customerEventsService.ts | Handles customer event tracking and audit logs | Analytics |
| developerProfileService.ts | Fetches developer profiles, templates, and analytics | Market |
| dialogService.ts | Provides dialog and modal management | UI |
| extensionService.ts | Manages extension registration and lifecycle | Extensions |
| litegraphService.ts | Provides utilities for working with the LiteGraph library | Graph |

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest'
import {
fetchDeveloperProfile,
fetchDeveloperReviews,
fetchDownloadHistory,
fetchPublishedTemplates,
fetchTemplateRevenue,
getCurrentUsername,
saveDeveloperProfile,
unpublishTemplate
} from '@/services/developerProfileService'
describe('developerProfileService', () => {
it('getCurrentUsername returns @StoneCypher', () => {
expect(getCurrentUsername()).toBe('@StoneCypher')
})
it('fetchDeveloperProfile returns a valid profile shape', async () => {
const profile = await fetchDeveloperProfile('@StoneCypher')
expect(profile.username).toBe('@StoneCypher')
expect(typeof profile.displayName).toBe('string')
expect(typeof profile.isVerified).toBe('boolean')
expect(typeof profile.monetizationEnabled).toBe('boolean')
expect(profile.joinedAt).toBeInstanceOf(Date)
expect(typeof profile.totalDownloads).toBe('number')
expect(typeof profile.totalFavorites).toBe('number')
expect(profile.averageRating).toBeGreaterThanOrEqual(0)
expect(profile.averageRating).toBeLessThanOrEqual(5)
expect(typeof profile.templateCount).toBe('number')
})
it('fetchDeveloperReviews returns reviews with valid ratings', async () => {
const reviews = await fetchDeveloperReviews('@StoneCypher')
expect(reviews.length).toBeGreaterThan(0)
for (const review of reviews) {
expect(review.rating).toBeGreaterThanOrEqual(1)
expect(review.rating).toBeLessThanOrEqual(5)
expect(review.rating % 0.5).toBe(0)
expect(typeof review.text).toBe('string')
expect(typeof review.authorName).toBe('string')
expect(review.createdAt).toBeInstanceOf(Date)
}
})
it('fetchPublishedTemplates returns templates with stats', async () => {
const templates = await fetchPublishedTemplates('@StoneCypher')
expect(templates.length).toBeGreaterThan(0)
for (const tpl of templates) {
expect(typeof tpl.id).toBe('string')
expect(typeof tpl.title).toBe('string')
expect(typeof tpl.stats.downloads).toBe('number')
expect(typeof tpl.stats.favorites).toBe('number')
expect(typeof tpl.stats.rating).toBe('number')
}
})
it('fetchTemplateRevenue returns revenue per template', async () => {
const revenue = await fetchTemplateRevenue('@StoneCypher')
expect(revenue.length).toBeGreaterThan(0)
for (const entry of revenue) {
expect(typeof entry.templateId).toBe('string')
expect(typeof entry.totalRevenue).toBe('number')
expect(typeof entry.monthlyRevenue).toBe('number')
expect(typeof entry.currency).toBe('string')
}
})
it('unpublishTemplate resolves without error', async () => {
await expect(unpublishTemplate('tpl-1')).resolves.toBeUndefined()
})
it('saveDeveloperProfile echoes back a complete profile', async () => {
const result = await saveDeveloperProfile({ bio: 'Updated bio' })
expect(result.bio).toBe('Updated bio')
expect(typeof result.username).toBe('string')
expect(typeof result.displayName).toBe('string')
expect(typeof result.isVerified).toBe('boolean')
})
it('fetchDeveloperProfile returns a different profile for @PixelWizard', async () => {
const profile = await fetchDeveloperProfile('@PixelWizard')
expect(profile.username).toBe('@PixelWizard')
expect(profile.displayName).toBe('Pixel Wizard')
expect(profile.isVerified).toBe(false)
})
it('fetchDeveloperProfile returns a fallback for unknown usernames', async () => {
const profile = await fetchDeveloperProfile('@Unknown')
expect(profile.username).toBe('@Unknown')
expect(profile.displayName).toBe('Unknown')
expect(profile.totalDownloads).toBe(0)
})
it('fetchPublishedTemplates returns different templates per developer', async () => {
const stoneTemplates = await fetchPublishedTemplates('@StoneCypher')
const pixelTemplates = await fetchPublishedTemplates('@PixelWizard')
const unknownTemplates = await fetchPublishedTemplates('@Unknown')
expect(stoneTemplates).toHaveLength(3)
expect(pixelTemplates).toHaveLength(1)
expect(pixelTemplates[0].title).toBe('Dreamy Landscapes img2img')
expect(unknownTemplates).toHaveLength(0)
})
it('fetchDeveloperReviews returns empty for unknown developers', async () => {
const reviews = await fetchDeveloperReviews('@Unknown')
expect(reviews).toHaveLength(0)
})
it('fetchTemplateRevenue returns empty for non-monetized developers', async () => {
const revenue = await fetchTemplateRevenue('@PixelWizard')
expect(revenue).toHaveLength(0)
})
it('fetchDownloadHistory returns 730 days of entries', async () => {
const history = await fetchDownloadHistory('@StoneCypher')
expect(history).toHaveLength(730)
for (const entry of history) {
expect(entry.date).toBeInstanceOf(Date)
expect(entry.downloads).toBeGreaterThanOrEqual(0)
}
})
it('fetchDownloadHistory returns entries in chronological order', async () => {
const history = await fetchDownloadHistory('@StoneCypher')
for (let i = 1; i < history.length; i++) {
expect(history[i].date.getTime()).toBeGreaterThan(
history[i - 1].date.getTime()
)
}
})
it('fetchDownloadHistory produces different data per username', async () => {
const stoneHistory = await fetchDownloadHistory('@StoneCypher')
const pixelHistory = await fetchDownloadHistory('@PixelWizard')
const stoneTotal = stoneHistory.reduce((s, e) => s + e.downloads, 0)
const pixelTotal = pixelHistory.reduce((s, e) => s + e.downloads, 0)
expect(stoneTotal).not.toBe(pixelTotal)
})
})

View File

@@ -0,0 +1,430 @@
/**
* Stub service for fetching and mutating developer profile data.
*
* Every function is named after the endpoint it will eventually call
* but currently returns hardcoded fake data. When multiple developers
* are known, the stub dispatches on the `username` parameter so the UI
* can be exercised with different profiles.
*/
import type {
DeveloperProfile,
DownloadHistoryEntry,
MarketplaceTemplate,
TemplateReview,
TemplateRevenue
} from '@/types/templateMarketplace'
const CURRENT_USERNAME = '@StoneCypher'
/**
* Returns the hardcoded current user's username for profile ownership checks.
*/
export function getCurrentUsername(): string {
return CURRENT_USERNAME
}
/** Stub profiles keyed by handle. */
const STUB_PROFILES: Record<string, DeveloperProfile> = {
'@StoneCypher': {
username: '@StoneCypher',
displayName: 'Stone Cypher',
avatarUrl: undefined,
bannerUrl: undefined,
bio: 'Workflow designer and custom node author. Building tools for the ComfyUI community.',
isVerified: true,
monetizationEnabled: true,
joinedAt: new Date('2024-03-15'),
dependencies: 371,
totalDownloads: 18_420,
totalFavorites: 1_273,
averageRating: 4.3,
templateCount: 3
},
'@PixelWizard': {
username: '@PixelWizard',
displayName: 'Pixel Wizard',
avatarUrl: undefined,
bannerUrl: undefined,
bio: 'Generative artist exploring the intersection of AI and traditional media.',
isVerified: false,
monetizationEnabled: false,
joinedAt: new Date('2025-01-10'),
dependencies: 12,
totalDownloads: 3_840,
totalFavorites: 295,
averageRating: 4.7,
templateCount: 1
}
}
function fallbackProfile(username: string): DeveloperProfile {
return {
username,
displayName: username.replace(/^@/, ''),
avatarUrl: undefined,
bannerUrl: undefined,
bio: undefined,
isVerified: false,
monetizationEnabled: false,
joinedAt: new Date(),
dependencies: 0,
totalDownloads: 0,
totalFavorites: 0,
averageRating: 0,
templateCount: 0
}
}
/**
* Fetches a developer's public profile by username.
*/
export async function fetchDeveloperProfile(
username: string
): Promise<DeveloperProfile> {
// comeback todo — replace with GET /api/v1/developers/{username}
return STUB_PROFILES[username] ?? fallbackProfile(username)
}
/** Stub reviews keyed by developer handle. */
const STUB_REVIEWS: Record<string, TemplateReview[]> = {
'@StoneCypher': [
{
id: 'rev-1',
authorName: 'PixelWizard',
authorAvatarUrl: undefined,
rating: 5,
text: 'Incredible workflow — saved me hours of manual setup. The ControlNet integration is seamless.',
createdAt: new Date('2025-11-02'),
templateId: 'tpl-1'
},
{
id: 'rev-2',
authorName: 'NeuralNomad',
authorAvatarUrl: undefined,
rating: 3.5,
text: 'Good starting point but the VRAM requirements are higher than listed. Works well on a 4090.',
createdAt: new Date('2025-10-18'),
templateId: 'tpl-2'
},
{
id: 'rev-3',
authorName: 'DiffusionDan',
authorAvatarUrl: undefined,
rating: 4,
text: 'Clean graph layout and well-documented. Would love to see a video tutorial added.',
createdAt: new Date('2025-09-30'),
templateId: 'tpl-1'
},
{
id: 'rev-4',
authorName: 'ArtBotAlice',
authorAvatarUrl: undefined,
rating: 2.5,
text: 'Had trouble with the LoRA loader step — might be a version mismatch. Otherwise decent.',
createdAt: new Date('2025-08-14'),
templateId: 'tpl-3'
}
],
'@PixelWizard': [
{
id: 'rev-5',
authorName: 'Stone Cypher',
authorAvatarUrl: undefined,
rating: 4.5,
text: 'Elegant approach to img2img. The parameter presets are a nice touch.',
createdAt: new Date('2025-12-01'),
templateId: 'tpl-pw-1'
}
]
}
/**
* Fetches reviews left on a developer's published templates.
*/
export async function fetchDeveloperReviews(
username: string
): Promise<TemplateReview[]> {
// comeback todo — replace with GET /api/v1/developers/{username}/reviews
return STUB_REVIEWS[username] ?? []
}
function makeAuthor(
username: string,
profile: DeveloperProfile
): MarketplaceTemplate['author'] {
return {
id: `usr-${username}`,
name: profile.displayName,
isVerified: profile.isVerified,
profileUrl: `/developers/${username}`
}
}
/** Stub templates keyed by developer handle. */
function stubTemplatesFor(username: string): MarketplaceTemplate[] {
const profile = STUB_PROFILES[username]
if (!profile) return []
const author = makeAuthor(username, profile)
const now = new Date()
if (username === '@PixelWizard') {
return [
{
id: 'tpl-pw-1',
title: 'Dreamy Landscapes img2img',
description:
'Transform rough sketches into dreamy landscape paintings with guided diffusion.',
shortDescription: 'Sketch-to-landscape with img2img',
author,
categories: ['image-generation'],
tags: ['img2img', 'landscape', 'painting'],
difficulty: 'beginner',
requiredModels: [
{ name: 'SD 1.5', type: 'checkpoint', size: 4_200_000_000 }
],
requiredNodes: [],
requiresCustomNodes: [],
vramRequirement: 6_000_000_000,
thumbnail: '',
gallery: [],
workflowPreview: '',
license: 'cc-by',
version: '1.0.0',
status: 'approved',
publishedAt: new Date('2025-02-15'),
updatedAt: now,
stats: {
downloads: 3_840,
favorites: 295,
rating: 4.7,
reviewCount: 8,
weeklyTrend: 3.1
}
}
]
}
// @StoneCypher templates
return [
{
id: 'tpl-1',
title: 'SDXL Turbo Portrait Studio',
description:
'End-to-end portrait generation with face correction and upscaling.',
shortDescription: 'Fast portrait generation with SDXL Turbo',
author,
categories: ['image-generation'],
tags: ['portrait', 'sdxl', 'turbo'],
difficulty: 'beginner',
requiredModels: [
{ name: 'SDXL Turbo', type: 'checkpoint', size: 6_500_000_000 }
],
requiredNodes: [],
requiresCustomNodes: [],
vramRequirement: 8_000_000_000,
thumbnail: '',
gallery: [],
workflowPreview: '',
license: 'cc-by',
version: '1.2.0',
status: 'approved',
publishedAt: new Date('2025-06-01'),
updatedAt: now,
stats: {
downloads: 9_200,
favorites: 680,
rating: 4.5,
reviewCount: 42,
weeklyTrend: 5.2
}
},
{
id: 'tpl-2',
title: 'ControlNet Depth-to-Image',
description:
'Depth-guided image generation using MiDaS depth estimation and ControlNet.',
shortDescription: 'Depth-guided generation with ControlNet',
author,
categories: ['image-generation'],
tags: ['controlnet', 'depth', 'midas'],
difficulty: 'intermediate',
requiredModels: [
{ name: 'SD 1.5', type: 'checkpoint', size: 4_200_000_000 },
{ name: 'ControlNet Depth', type: 'controlnet', size: 1_400_000_000 }
],
requiredNodes: [],
requiresCustomNodes: [],
vramRequirement: 10_000_000_000,
thumbnail: '',
gallery: [],
workflowPreview: '',
license: 'mit',
version: '2.0.1',
status: 'approved',
publishedAt: new Date('2025-04-12'),
updatedAt: now,
stats: {
downloads: 6_800,
favorites: 410,
rating: 3.8,
reviewCount: 27,
weeklyTrend: -1.3
}
},
{
id: 'tpl-3',
title: 'LoRA Style Transfer Pipeline',
description:
'Apply artistic styles via LoRA fine-tunes with automatic strength scheduling.',
shortDescription: 'Artistic style transfer with LoRA',
author,
categories: ['style-transfer'],
tags: ['lora', 'style', 'art'],
difficulty: 'advanced',
requiredModels: [
{ name: 'SD 1.5', type: 'checkpoint', size: 4_200_000_000 },
{ name: 'Watercolor LoRA', type: 'lora', size: 150_000_000 }
],
requiredNodes: [],
requiresCustomNodes: [],
vramRequirement: 6_000_000_000,
thumbnail: '',
gallery: [],
workflowPreview: '',
license: 'cc-by-sa',
version: '1.0.0',
status: 'approved',
publishedAt: new Date('2025-08-20'),
updatedAt: now,
stats: {
downloads: 2_420,
favorites: 183,
rating: 4.1,
reviewCount: 11,
weeklyTrend: 12.7
}
}
]
}
/**
* Fetches all templates published by a developer.
*/
export async function fetchPublishedTemplates(
username: string
): Promise<MarketplaceTemplate[]> {
// comeback todo — replace with GET /api/v1/developers/{username}/templates
return stubTemplatesFor(username)
}
/** Stub revenue keyed by developer handle. */
const STUB_REVENUE: Record<string, TemplateRevenue[]> = {
'@StoneCypher': [
{
templateId: 'tpl-1',
totalRevenue: 45_230,
monthlyRevenue: 3_800,
currency: 'USD'
},
{
templateId: 'tpl-2',
totalRevenue: 22_110,
monthlyRevenue: 1_450,
currency: 'USD'
},
{
templateId: 'tpl-3',
totalRevenue: 8_920,
monthlyRevenue: 920,
currency: 'USD'
}
]
}
/**
* Fetches revenue data for a developer's templates.
* Only accessible by the template author.
*/
export async function fetchTemplateRevenue(
username: string
): Promise<TemplateRevenue[]> {
// comeback todo — replace with GET /api/v1/developers/{username}/revenue
return STUB_REVENUE[username] ?? []
}
/**
* Unpublishes a template by its ID.
*/
export async function unpublishTemplate(_templateId: string): Promise<void> {
// comeback todo — replace with POST /api/v1/templates/{templateId}/unpublish
}
/**
* Saves changes to a developer's profile.
*/
export async function saveDeveloperProfile(
profile: Partial<DeveloperProfile>
): Promise<DeveloperProfile> {
// comeback todo — replace with PUT /api/v1/developers/{username}
const base =
STUB_PROFILES[profile.username ?? CURRENT_USERNAME] ??
fallbackProfile(profile.username ?? CURRENT_USERNAME)
return { ...base, ...profile } as DeveloperProfile
}
/**
* Generates a deterministic pseudo-random daily download history starting
* from 730 days ago (two years). The seed is derived from the username so
* different profiles produce different but stable curves.
*/
function stubDownloadHistory(username: string): DownloadHistoryEntry[] {
const days = 730
const entries: DownloadHistoryEntry[] = []
const baseDownloads = username === '@StoneCypher' ? 42 : 12
let seed = 0
for (const ch of username) seed = (seed * 31 + ch.charCodeAt(0)) | 0
function nextRand(): number {
seed = (seed * 16807 + 0) % 2147483647
return (seed & 0x7fffffff) / 2147483647
}
const now = new Date()
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
date.setHours(0, 0, 0, 0)
const dayOfWeek = date.getDay()
const weekendMultiplier = dayOfWeek === 0 || dayOfWeek === 6 ? 1.4 : 1.0
const trendMultiplier = 1 + (days - i) / days
const noise = 0.5 + nextRand()
entries.push({
date,
downloads: Math.round(
baseDownloads * weekendMultiplier * trendMultiplier * noise
)
})
}
return entries
}
/**
* Fetches daily download history for a developer's templates.
*
* Returns one entry per day, spanning two years, in chronological order.
* Each entry records the aggregate download count across all of the
* developer's published templates for that calendar day.
*
* @param username - Developer handle (e.g. '@StoneCypher').
* @returns Chronologically-ordered daily download entries.
*/
export async function fetchDownloadHistory(
username: string
): Promise<DownloadHistoryEntry[]> {
// comeback
return stubDownloadHistory(username)
}

View File

@@ -0,0 +1,236 @@
/**
* An in-memory cached file awaiting upload, retaining its original filename
* and a blob URL for local preview.
*/
export interface CachedAsset {
/** The raw File object from the user's file picker. */
file: File
/** A `blob:` URL created via `URL.createObjectURL` for local display. */
objectUrl: string
/** The filename as it appeared on the user's filesystem. */
originalName: string
}
/**
* A workflow template listed in the marketplace, including its metadata,
* media assets, technical requirements, and publication status.
*/
export interface MarketplaceTemplate {
/** Unique identifier for this template. */
id: string
/** Display title shown in the marketplace. */
title: string
/** Full description with usage details and context. */
description: string
/** One-line summary shown in card views and search results. */
shortDescription: string
/** Profile of the template author. */
author: AuthorInfo
/** Semantic categories (e.g. 'image-generation', 'audio'). */
categories: string[]
/** Freeform tags for search and filtering. */
tags: string[]
/** Skill level assumed by the template's instructions and complexity. */
difficulty: 'beginner' | 'intermediate' | 'advanced'
/** Model files that must be downloaded before running this template. */
requiredModels: ModelRequirement[]
/** Custom node package IDs required by this template. */
requiredNodes: string[]
/**
* Custom node package IDs (folder names from `custom_nodes/`) required by
* this template. Derived from the `python_module` of each custom node
* definition found in the workflow graph.
*/
requiresCustomNodes: string[]
/** Minimum VRAM in bytes needed to run this template. */
vramRequirement: number
/** URL to the primary thumbnail image. */
thumbnail: string
/** URL to the "before" image in a before/after comparison. */
beforeImage?: string
/** URL to the "after" image in a before/after comparison. */
afterImage?: string
/** Ordered collection of images and videos showcasing the template. */
gallery: GalleryItem[]
/** URL to an optional video walkthrough or demo. */
videoPreview?: string
/** URL to a static image preview of the workflow graph. */
workflowPreview: string
/** License governing usage and redistribution. */
license: LicenseType
/** URL to an external tutorial or guide for this template. */
tutorialUrl?: string
/** Semantic version string (e.g. '1.2.0'). */
version: string
/** Current publication lifecycle stage. */
status: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'unpublished'
/** Reviewer notes returned when the template is rejected. */
reviewFeedback?: string
/** Timestamp when the template was first published. */
publishedAt?: Date
/** Timestamp of the most recent content update. */
updatedAt: Date
/** Aggregate engagement and popularity metrics. */
stats: TemplateStats
}
/**
* Author profile information displayed alongside a marketplace template.
*/
export interface AuthorInfo {
/** Unique author identifier. */
id: string
/** Display name. */
name: string
/** URL to the author's avatar image. */
avatarUrl?: string
/** Whether the author's identity has been verified by the platform. */
isVerified: boolean
/** URL to the author's public profile page. */
profileUrl: string
}
/**
* Aggregate engagement and popularity statistics for a marketplace template.
*/
export interface TemplateStats {
/** Total number of times this template has been downloaded. */
downloads: number
/** Number of users who have favorited this template. */
favorites: number
/** Average user rating (e.g. 0-5 scale). */
rating: number
/** Total number of user reviews submitted. */
reviewCount: number
/** Week-over-week percentage change in downloads. */
weeklyTrend: number
}
/**
* A single image or video entry in a template's gallery.
*/
export interface GalleryItem {
/** Media format of this gallery entry. */
type: 'image' | 'video'
/** URL to the media asset. */
url: string
/** Optional descriptive caption displayed below the media. */
caption?: string
/** When true, marks this item as the "before" half of a before/after pair. */
isBefore?: boolean
}
/**
* A model dependency required to run a marketplace template.
*/
export interface ModelRequirement {
/** Human-readable model name. */
name: string
/** Architecture category of the model. */
type: 'checkpoint' | 'lora' | 'controlnet' | 'vae'
/** Download URL for the model file. */
url?: string
/** File size in bytes. */
size: number
}
/**
* Supported license types for marketplace templates.
*/
export type LicenseType =
| 'cc-by'
| 'cc-by-sa'
| 'cc-by-nc'
| 'mit'
| 'apache'
| 'custom'
/**
* A developer's public profile containing their identity,
* published templates, and aggregate statistics.
*/
export interface DeveloperProfile {
/** Handle shown in URLs and mentions (e.g. '@StoneCypher'). */
username: string
/** Human-readable display name. */
displayName: string
/** URL to the developer's avatar image. */
avatarUrl?: string
/** URL to a wide banner image displayed at the top of the profile. */
bannerUrl?: string
/** Short biography or tagline. */
bio?: string
/** Whether the developer's identity has been verified by the platform. */
isVerified: boolean
/** Whether the developer has opted into revenue sharing. */
monetizationEnabled: boolean
/** Date the developer joined the platform. */
joinedAt: Date
/** Number of other templates that depend on this developer's work. */
dependencies: number
/** Lifetime download count across all published templates. */
totalDownloads: number
/** Lifetime favorite count across all published templates. */
totalFavorites: number
/** Average star rating across all published templates. */
averageRating: number
/** Number of published templates. */
templateCount: number
}
/**
* A user review of a marketplace template, including a star rating
* and optional text commentary.
*/
export interface TemplateReview {
/** Unique review identifier. */
id: string
/** Display name of the reviewer. */
authorName: string
/** URL to the reviewer's avatar image. */
authorAvatarUrl?: string
/** Star rating from 1 to 5, supporting 0.5 increments. */
rating: number
/** Review body text. */
text: string
/** When the review was submitted. */
createdAt: Date
/** ID of the template being reviewed. */
templateId: string
}
/**
* A single day's download count in a developer's download history.
*/
export interface DownloadHistoryEntry {
/** The calendar date this count represents (midnight UTC). */
date: Date
/** Number of downloads recorded on this date. */
downloads: number
}
/**
* Time range options for the download history chart.
*/
export type DownloadHistoryRange = 'week' | 'month' | 'year' | 'allTime'
/**
* Revenue information for a single published template,
* visible only to the template's author when monetization is enabled.
*/
export interface TemplateRevenue {
/** ID of the template this revenue data belongs to. */
templateId: string
/** Lifetime revenue in cents. */
totalRevenue: number
/** Revenue earned in the current calendar month, in cents. */
monthlyRevenue: number
/** ISO 4217 currency code (e.g. 'USD'). */
currency: string
}

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest'
import type { TemplateSimilarityInput } from '@/utils/templateSimilarity'
import {
findSimilarTemplates,
toSimilarityInput
} from '@/utils/templateSimilarity'
function makeTemplate(
overrides: Partial<TemplateSimilarityInput> & { name: string }
): TemplateSimilarityInput {
return {
categories: [],
tags: [],
models: [],
requiredNodes: [],
...overrides
}
}
describe('templateSimilarity', () => {
describe('findSimilarTemplates', () => {
it('returns templates sorted by descending similarity score', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev']
})
const bestMatch = makeTemplate({
name: 'best',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev']
})
const partialMatch = makeTemplate({
name: 'partial',
categories: ['image-generation'],
tags: ['sdxl']
})
const weakMatch = makeTemplate({
name: 'weak',
tags: ['flux']
})
const results = findSimilarTemplates(reference, [
weakMatch,
bestMatch,
partialMatch
])
expect(results.map((r) => r.template.name)).toEqual([
'best',
'partial',
'weak'
])
})
it('excludes the reference template from results', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation']
})
const clone = makeTemplate({
name: 'ref',
categories: ['image-generation']
})
const other = makeTemplate({
name: 'other',
categories: ['image-generation']
})
const results = findSimilarTemplates(reference, [clone, other])
expect(results).toHaveLength(1)
expect(results[0].template.name).toBe('other')
})
it('excludes templates with zero similarity', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation'],
tags: ['flux']
})
const noOverlap = makeTemplate({
name: 'none',
categories: ['audio'],
tags: ['tts']
})
const results = findSimilarTemplates(reference, [noOverlap])
expect(results).toHaveLength(0)
})
it('respects the limit parameter', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation']
})
const candidates = Array.from({ length: 20 }, (_, i) =>
makeTemplate({ name: `t-${i}`, categories: ['image-generation'] })
)
const results = findSimilarTemplates(reference, candidates, 5)
expect(results).toHaveLength(5)
})
it('returns empty array when no candidates match', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['video-generation']
})
const candidates = [
makeTemplate({ name: 'a', categories: ['audio'] }),
makeTemplate({ name: 'b', categories: ['text'] })
]
expect(findSimilarTemplates(reference, candidates)).toEqual([])
})
it('returns empty array when candidates list is empty', () => {
const reference = makeTemplate({ name: 'ref', tags: ['flux'] })
expect(findSimilarTemplates(reference, [])).toEqual([])
})
it('returns empty array when all templates have empty metadata', () => {
const reference = makeTemplate({ name: 'ref' })
const candidates = [
makeTemplate({ name: 'a' }),
makeTemplate({ name: 'b' })
]
expect(findSimilarTemplates(reference, candidates)).toEqual([])
})
it('ranks category match higher than tag-only match', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation'],
tags: ['flux']
})
const categoryOnly = makeTemplate({
name: 'cat',
categories: ['image-generation']
})
const tagOnly = makeTemplate({
name: 'tag',
tags: ['flux']
})
const results = findSimilarTemplates(reference, [tagOnly, categoryOnly])
expect(results[0].template.name).toBe('cat')
expect(results[0].score).toBeGreaterThan(results[1].score)
})
it('ranks shared models higher than shared tags', () => {
const reference = makeTemplate({
name: 'ref',
tags: ['txt2img'],
models: ['flux-dev']
})
const modelMatch = makeTemplate({
name: 'model',
models: ['flux-dev']
})
const tagMatch = makeTemplate({
name: 'tag',
tags: ['txt2img']
})
const results = findSimilarTemplates(reference, [tagMatch, modelMatch])
expect(results[0].template.name).toBe('model')
expect(results[0].score).toBeGreaterThan(results[1].score)
})
it('scores identical templates at 1.0', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev'],
requiredNodes: ['node-a']
})
const identical = makeTemplate({
name: 'twin',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev'],
requiredNodes: ['node-a']
})
const results = findSimilarTemplates(reference, [identical])
expect(results[0].score).toBeCloseTo(1.0)
})
it('scores a real-world scenario correctly', () => {
const reference = makeTemplate({
name: 'flux-basic',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev']
})
const similar = makeTemplate({
name: 'flux-advanced',
categories: ['image-generation'],
tags: ['flux', 'img2img'],
models: ['flux-dev']
})
const unrelated = makeTemplate({
name: 'audio-gen',
categories: ['audio'],
tags: ['tts', 'speech'],
models: ['bark']
})
const results = findSimilarTemplates(reference, [unrelated, similar])
expect(results).toHaveLength(1)
expect(results[0].template.name).toBe('flux-advanced')
expect(results[0].score).toBeGreaterThan(0.5)
})
})
describe('toSimilarityInput', () => {
it('wraps single category string into array', () => {
const result = toSimilarityInput({
name: 'test',
category: 'image-generation',
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.categories).toEqual(['image-generation'])
})
it('returns empty categories when category is undefined', () => {
const result = toSimilarityInput({
name: 'test',
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.categories).toEqual([])
})
it('passes tags through unchanged', () => {
const result = toSimilarityInput({
name: 'test',
tags: ['flux', 'txt2img'],
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.tags).toEqual(['flux', 'txt2img'])
})
it('passes models through unchanged', () => {
const result = toSimilarityInput({
name: 'test',
models: ['flux-dev', 'sd-vae'],
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.models).toEqual(['flux-dev', 'sd-vae'])
})
it('maps requiresCustomNodes to requiredNodes', () => {
const result = toSimilarityInput({
name: 'test',
requiresCustomNodes: ['node-a', 'node-b'],
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.requiredNodes).toEqual(['node-a', 'node-b'])
})
it('defaults missing arrays to empty arrays', () => {
const result = toSimilarityInput({
name: 'test',
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.tags).toEqual([])
expect(result.models).toEqual([])
expect(result.requiredNodes).toEqual([])
})
})
})

View File

@@ -0,0 +1,113 @@
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { intersection, union } from 'es-toolkit'
/**
* Minimal metadata fields used by the similarity scorer.
* Both EnhancedTemplate and MarketplaceTemplate can satisfy this
* interface directly or via {@link toSimilarityInput}.
*/
export interface TemplateSimilarityInput {
readonly name: string
readonly categories?: readonly string[]
readonly tags?: readonly string[]
readonly models?: readonly string[]
readonly requiredNodes?: readonly string[]
}
/**
* A candidate template paired with its similarity score.
*/
export interface SimilarTemplate<T extends TemplateSimilarityInput> {
readonly template: T
readonly score: number
}
/** Per-dimension weights for the similarity formula. */
const SIMILARITY_WEIGHTS = {
categories: 0.35,
tags: 0.25,
models: 0.3,
requiredNodes: 0.1
} as const
/**
* Compute Jaccard similarity between two string arrays.
* Returns 0 when both arrays are empty (no evidence of similarity).
*/
function jaccardSimilarity(a: readonly string[], b: readonly string[]): number {
const u = union([...a], [...b])
if (u.length === 0) return 0
return intersection([...a], [...b]).length / u.length
}
/**
* Score the similarity between two templates based on shared metadata.
* Returns a value in [0, 1] where 1 is identical and 0 is no overlap.
*/
export function computeSimilarity(
reference: TemplateSimilarityInput,
candidate: TemplateSimilarityInput
): number {
return (
SIMILARITY_WEIGHTS.categories *
jaccardSimilarity(
reference.categories ?? [],
candidate.categories ?? []
) +
SIMILARITY_WEIGHTS.tags *
jaccardSimilarity(reference.tags ?? [], candidate.tags ?? []) +
SIMILARITY_WEIGHTS.models *
jaccardSimilarity(reference.models ?? [], candidate.models ?? []) +
SIMILARITY_WEIGHTS.requiredNodes *
jaccardSimilarity(
reference.requiredNodes ?? [],
candidate.requiredNodes ?? []
)
)
}
/**
* Find templates similar to a reference, sorted by descending similarity.
* Excludes the reference template itself (matched by name) and any
* candidates with zero similarity.
*
* @param reference - The template to find similar templates for
* @param candidates - The pool of templates to score against
* @param limit - Maximum number of results to return (default: 10)
* @returns Sorted array of similar templates with their scores
*/
export function findSimilarTemplates<T extends TemplateSimilarityInput>(
reference: TemplateSimilarityInput,
candidates: readonly T[],
limit: number = 10
): SimilarTemplate<T>[] {
return candidates
.filter((c) => c.name !== reference.name)
.map((candidate) => ({
template: candidate,
score: computeSimilarity(reference, candidate)
}))
.filter((result) => result.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
}
/**
* Normalize an EnhancedTemplate (or TemplateInfo with category) to
* the shape expected by the similarity scorer.
*
* Wraps the single `category` string into an array and maps
* `requiresCustomNodes` to `requiredNodes`.
*/
export function toSimilarityInput(
template: TemplateInfo & { category?: string }
): TemplateSimilarityInput {
return {
name: template.name,
categories: template.category ? [template.category] : [],
tags: template.tags ?? [],
models: template.models ?? [],
requiredNodes: template.requiresCustomNodes ?? []
}
}

View File

@@ -8,9 +8,10 @@
v-show="!linearMode"
id="graph-canvas-container"
ref="graphCanvasContainerRef"
class="graph-canvas-container"
class="graph-canvas-container relative"
>
<GraphCanvas @ready="onGraphReady" />
<VramEstimateIndicator />
</div>
<LinearView v-if="linearMode" />
<BuilderToolbar v-if="appModeStore.isBuilderMode" />
@@ -46,6 +47,7 @@ import { runWhenGlobalIdle } from '@/base/common/async'
import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import VramEstimateIndicator from '@/components/graph/VramEstimateIndicator.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAcceptedToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'