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>
This commit is contained in:
John Haugeland
2026-02-24 16:44:48 -08:00
parent 55dea32e00
commit 8361122586
39 changed files with 4605 additions and 22 deletions

View File

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

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,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

@@ -43,7 +43,10 @@
{{ t('templatePublishing.previous') }}
</Button>
<Button
:disabled="currentStep >= totalSteps - 1"
:disabled="
currentStep >= totalSteps - 1 ||
currentStep === STEP_PAGE_MAP.preview
"
size="lg"
@click="nextStep"
>

View File

@@ -14,7 +14,8 @@ const mockNodes = vi.hoisted(() => [
{ type: 'KSampler', isSubgraphNode: () => false },
{ type: 'MyCustomNode', isSubgraphNode: () => false },
{ type: 'AnotherCustom', isSubgraphNode: () => false },
{ type: 'MyCustomNode', isSubgraphNode: () => false }
{ type: 'MyCustomNode', isSubgraphNode: () => false },
{ type: 'ExtraCustomPack', isSubgraphNode: () => false }
])
vi.mock('@vueuse/core', async (importOriginal) => {
@@ -53,17 +54,29 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeDefsByName: {
KSampler: { name: 'KSampler', nodeSource: { type: NodeSourceType.Core } },
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 }
}
}
@@ -194,6 +207,25 @@ describe('StepTemplatePublishingMetadata', () => {
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)
@@ -225,20 +257,20 @@ describe('StepTemplatePublishingMetadata', () => {
const input = wrapper.find('.relative input[type="text"]')
await input.trigger('focus')
await input.setValue('Extra')
await input.setValue('Unused')
const suggestions = wrapper.findAll('.relative ul li')
expect(suggestions.length).toBe(1)
expect(suggestions[0].text()).toBe('ExtraCustomPack')
expect(suggestions[0].text()).toBe('UnusedCustomNode')
})
it('excludes already-added nodes from suggestions', async () => {
const ctx = createContext({ requiredNodes: ['ExtraCustomPack'] })
const ctx = createContext({ requiredNodes: ['UnusedCustomNode'] })
const { wrapper } = mountStep(ctx)
const input = wrapper.find('.relative input[type="text"]')
await input.trigger('focus')
await input.setValue('Extra')
await input.setValue('Unused')
const suggestions = wrapper.findAll('.relative ul li')
expect(suggestions.length).toBe(0)
@@ -250,11 +282,11 @@ describe('StepTemplatePublishingMetadata', () => {
const input = wrapper.find('.relative input[type="text"]')
await input.trigger('focus')
await input.setValue('Extra')
await input.setValue('Unused')
const suggestion = wrapper.find('.relative ul li')
await suggestion.trigger('mousedown')
expect(ctx.template.value.requiredNodes).toContain('ExtraCustomPack')
expect(ctx.template.value.requiredNodes).toContain('UnusedCustomNode')
})
})

View File

@@ -224,6 +224,41 @@ function detectCustomNodes(): string[] {
.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[]>([])
onMounted(() => {
@@ -233,6 +268,11 @@ onMounted(() => {
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()
}
})
const manualNodes = computed(() => {

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

@@ -1,3 +1,287 @@
<!--
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 p-6"></div>
<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>
</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 { 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}`)
})
</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

@@ -1,3 +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 p-6"></div>
<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

@@ -3,11 +3,21 @@
<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

@@ -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,7 @@ 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'
@@ -350,11 +351,19 @@ export function useCoreCommands(): ComfyCommand[] {
// comeback
id: 'Comfy.ShowTemplatePublishing',
icon: 'pi pi-objects-column',
label: 'Show Template Publishing',
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

@@ -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

@@ -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

@@ -207,6 +207,15 @@ export function useWorkflowActionsMenu(
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

@@ -1138,7 +1138,20 @@
},
"preview": {
"title": "Preview",
"description": "Review your template before submitting"
"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",
"notProvided": "Not provided",
"noneDetected": "None detected",
"correct": "Correct",
"editStep": "Edit"
},
"submissionForReview": {
"title": "Submit",
@@ -1156,6 +1169,7 @@
"bio": "Bio",
"reviews": "Reviews",
"publishedTemplates": "Published Templates",
"dependencies": "Dependencies",
"totalDownloads": "Downloads",
"totalFavorites": "Favorites",
"averageRating": "Avg. Rating",
@@ -1173,9 +1187,17 @@
"bannerPlaceholder": "Banner image",
"editUsername": "Edit username",
"editBio": "Edit bio",
"lookupHandle": "Enter developer handle\u2026",
"downloads": "Downloads",
"favorites": "Favorites",
"rating": "Rating"
"rating": "Rating",
"downloadHistory": "Download History",
"range": {
"week": "Week",
"month": "Month",
"year": "Year",
"allTime": "All Time"
}
},
"subgraphStore": {
"confirmDeleteTitle": "Delete blueprint?",

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

@@ -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

@@ -1,36 +1,82 @@
/**
* 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[]
vramRequirement: number // Minimum VRAM in bytes needed to run this template
/**
* 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
workflowPreview: string // URL to a static image preview of the workflow graph
/** 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
}
@@ -38,10 +84,15 @@ export interface MarketplaceTemplate {
* 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
}
@@ -49,31 +100,44 @@ export interface AuthorInfo {
* 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
weeklyTrend: number // Week-over-week percentage change in downloads
/** 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
isBefore?: boolean // When true, indicates this item is the "before" half of a before/after pair
/** 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'
url?: string // Download URL for the model
size: number // in bytes
/** Download URL for the model file. */
url?: string
/** File size in bytes. */
size: number
}
/**
@@ -86,3 +150,87 @@ export type LicenseType =
| '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
}