mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
feat: template publishing dialog, stepper, and step components
Rename template marketplace to template publishing throughout: - Replace useTemplateMarketplaceDialog with useTemplatePublishingDialog - Update core command from Comfy.ShowTemplateMarketplace to Comfy.ShowTemplatePublishing - Update workflow actions menu label and command reference Add multi-step publishing dialog infrastructure: - TemplatePublishingDialog.vue with step-based navigation - TemplatePublishingStepperNav.vue for step progress indicator - useTemplatePublishingStepper composable managing step state, navigation, and validation - Step components for each phase: landing, metadata, description, preview generation, category/tagging, preview, submission, complete - StepTemplatePublishingMetadata with form fields for title, category, tags, difficulty, and license selection - StepTemplatePublishingDescription with markdown editor and live preview via vue-i18n Add comprehensive i18n entries for all publishing steps, form labels, difficulty levels, license types, and category names. Add tests for dialog lifecycle, stepper navigation/validation, metadata form interaction, and description editing. Bump version to 1.42.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.3",
|
||||
"version": "1.42.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplatePublishStorage',
|
||||
() => ({
|
||||
loadTemplateUnderway: vi.fn(() => null),
|
||||
saveTemplateUnderway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
import TemplatePublishingDialog from './TemplatePublishingDialog.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
dialogTitle: 'Template Publishing',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
saveDraft: 'Save Draft',
|
||||
stepProgress: 'Step {current} of {total}',
|
||||
steps: {
|
||||
landing: {
|
||||
title: 'Getting Started',
|
||||
description: 'Overview of the publishing process'
|
||||
},
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
description: 'Title, description, and author info'
|
||||
},
|
||||
description: {
|
||||
title: 'Description',
|
||||
description: 'Write a detailed description of your template'
|
||||
},
|
||||
previewGeneration: {
|
||||
title: 'Preview',
|
||||
description: 'Generate preview images and videos'
|
||||
},
|
||||
categoryAndTagging: {
|
||||
title: 'Categories & Tags',
|
||||
description: 'Categorize and tag your template'
|
||||
},
|
||||
preview: {
|
||||
title: 'Preview',
|
||||
description: 'Review your template before submitting'
|
||||
},
|
||||
submissionForReview: {
|
||||
title: 'Submit',
|
||||
description: 'Submit your template for review'
|
||||
},
|
||||
complete: {
|
||||
title: 'Complete',
|
||||
description: 'Your template has been submitted'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountDialog(props?: { initialPage?: string }) {
|
||||
return mount(TemplatePublishingDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseModalLayout: {
|
||||
template: `
|
||||
<div data-testid="modal">
|
||||
<div data-testid="left-panel"><slot name="leftPanel" /></div>
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-right"><slot name="header-right-area" /></div>
|
||||
<div data-testid="content"><slot name="content" /></div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
TemplatePublishingStepperNav: {
|
||||
template: '<div data-testid="stepper-nav" />',
|
||||
props: ['currentStep', 'stepDefinitions']
|
||||
},
|
||||
StepTemplatePublishingLanding: {
|
||||
template: '<div data-testid="step-landing" />'
|
||||
},
|
||||
StepTemplatePublishingMetadata: {
|
||||
template: '<div data-testid="step-metadata" />'
|
||||
},
|
||||
StepTemplatePublishingDescription: {
|
||||
template: '<div data-testid="step-description" />'
|
||||
},
|
||||
StepTemplatePublishingPreviewGeneration: {
|
||||
template: '<div data-testid="step-preview-generation" />'
|
||||
},
|
||||
StepTemplatePublishingCategoryAndTagging: {
|
||||
template: '<div data-testid="step-category" />'
|
||||
},
|
||||
StepTemplatePublishingPreview: {
|
||||
template: '<div data-testid="step-preview" />'
|
||||
},
|
||||
StepTemplatePublishingSubmissionForReview: {
|
||||
template: '<div data-testid="step-submission" />'
|
||||
},
|
||||
StepTemplatePublishingComplete: {
|
||||
template: '<div data-testid="step-complete" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplatePublishingDialog', () => {
|
||||
it('renders the dialog with the first step by default', () => {
|
||||
const wrapper = mountDialog()
|
||||
expect(wrapper.find('[data-testid="modal"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the stepper nav in the left panel', () => {
|
||||
const wrapper = mountDialog()
|
||||
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
||||
expect(leftPanel.find('[data-testid="stepper-nav"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('maps initialPage to the correct starting step', () => {
|
||||
const wrapper = mountDialog({ initialPage: 'metadata' })
|
||||
expect(wrapper.find('[data-testid="step-metadata"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to step 1 for unknown initialPage', () => {
|
||||
const wrapper = mountDialog({ initialPage: 'nonexistent' })
|
||||
expect(wrapper.find('[data-testid="step-landing"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows Previous button when not on first step', () => {
|
||||
const wrapper = mountDialog({ initialPage: 'metadata' })
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const buttons = headerRight.findAll('button')
|
||||
const buttonTexts = buttons.map((b) => b.text())
|
||||
expect(buttonTexts.some((text) => text.includes('Previous'))).toBe(true)
|
||||
})
|
||||
|
||||
it('disables Previous button on first step', () => {
|
||||
const wrapper = mountDialog()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const prevButton = headerRight
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Previous'))
|
||||
expect(prevButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Next button on last step', () => {
|
||||
const wrapper = mountDialog({
|
||||
initialPage: 'complete'
|
||||
})
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const nextButton = headerRight
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Next'))
|
||||
expect(nextButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables Next button on submit step', () => {
|
||||
const wrapper = mountDialog({
|
||||
initialPage: 'submissionForReview'
|
||||
})
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
|
||||
const nextButton = headerRight
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Next'))
|
||||
expect(nextButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
153
src/components/templatePublishing/TemplatePublishingDialog.vue
Normal file
153
src/components/templatePublishing/TemplatePublishingDialog.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="t('templatePublishing.dialogTitle')"
|
||||
size="md"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--upload]" />
|
||||
<h2 class="text-neutral text-base">
|
||||
{{ t('templatePublishing.dialogTitle') }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<template #leftPanel>
|
||||
<TemplatePublishingStepperNav
|
||||
:current-step="currentStep"
|
||||
:step-definitions="stepDefinitions"
|
||||
@update:current-step="goToStep"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t('templatePublishing.stepProgress', {
|
||||
current: currentStep,
|
||||
total: totalSteps
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
:disabled="isFirstStep"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="prevStep"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left]" />
|
||||
{{ t('templatePublishing.previous') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="saveDraft">
|
||||
<i class="icon-[lucide--save]" />
|
||||
{{ t('templatePublishing.saveDraft') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="currentStep >= totalSteps - 1"
|
||||
size="lg"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ t('templatePublishing.next') }}
|
||||
<i class="icon-[lucide--arrow-right]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<component :is="activeStepComponent" />
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import { useTemplatePublishingStepper } from '@/composables/useTemplatePublishingStepper'
|
||||
|
||||
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
|
||||
import StepTemplatePublishingCategoryAndTagging from './steps/StepTemplatePublishingCategoryAndTagging.vue'
|
||||
import StepTemplatePublishingComplete from './steps/StepTemplatePublishingComplete.vue'
|
||||
import StepTemplatePublishingDescription from './steps/StepTemplatePublishingDescription.vue'
|
||||
import StepTemplatePublishingLanding from './steps/StepTemplatePublishingLanding.vue'
|
||||
import StepTemplatePublishingMetadata from './steps/StepTemplatePublishingMetadata.vue'
|
||||
import StepTemplatePublishingPreview from './steps/StepTemplatePublishingPreview.vue'
|
||||
import StepTemplatePublishingPreviewGeneration from './steps/StepTemplatePublishingPreviewGeneration.vue'
|
||||
import StepTemplatePublishingSubmissionForReview from './steps/StepTemplatePublishingSubmissionForReview.vue'
|
||||
import { PublishingStepperKey } from './types'
|
||||
|
||||
const { onClose, initialPage } = defineProps<{
|
||||
onClose: () => void
|
||||
initialPage?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const STEP_PAGE_MAP: Record<string, number> = {
|
||||
publishingLanding: 1,
|
||||
metadata: 2,
|
||||
description: 3,
|
||||
previewGeneration: 4,
|
||||
categoryAndTagging: 5,
|
||||
preview: 6,
|
||||
submissionForReview: 7,
|
||||
complete: 8
|
||||
}
|
||||
|
||||
const initialStep = initialPage ? (STEP_PAGE_MAP[initialPage] ?? 1) : 1
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
template,
|
||||
stepDefinitions,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canProceed,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
saveDraft,
|
||||
setStepValid
|
||||
} = useTemplatePublishingStepper({ initialStep })
|
||||
|
||||
const STEP_COMPONENTS: Component[] = [
|
||||
StepTemplatePublishingLanding,
|
||||
StepTemplatePublishingMetadata,
|
||||
StepTemplatePublishingDescription,
|
||||
StepTemplatePublishingPreviewGeneration,
|
||||
StepTemplatePublishingCategoryAndTagging,
|
||||
StepTemplatePublishingPreview,
|
||||
StepTemplatePublishingSubmissionForReview,
|
||||
StepTemplatePublishingComplete
|
||||
]
|
||||
|
||||
const activeStepComponent = computed(
|
||||
() => STEP_COMPONENTS[currentStep.value - 1]
|
||||
)
|
||||
|
||||
provide(PublishingStepperKey, {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canProceed,
|
||||
template,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
saveDraft,
|
||||
setStepValid
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PublishingStepDefinition } from './types'
|
||||
|
||||
import TemplatePublishingStepperNav from './TemplatePublishingStepperNav.vue'
|
||||
|
||||
const STEP_DEFINITIONS: PublishingStepDefinition[] = [
|
||||
{
|
||||
number: 1,
|
||||
titleKey: 'steps.landing.title',
|
||||
descriptionKey: 'steps.landing.description'
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
titleKey: 'steps.metadata.title',
|
||||
descriptionKey: 'steps.metadata.description'
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
titleKey: 'steps.preview.title',
|
||||
descriptionKey: 'steps.preview.description'
|
||||
}
|
||||
]
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
steps: {
|
||||
landing: { title: 'Getting Started', description: '' },
|
||||
metadata: { title: 'Metadata', description: '' },
|
||||
preview: { title: 'Preview', description: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountNav(props?: { currentStep?: number }) {
|
||||
return mount(TemplatePublishingStepperNav, {
|
||||
props: {
|
||||
currentStep: props?.currentStep ?? 1,
|
||||
stepDefinitions: STEP_DEFINITIONS
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplatePublishingStepperNav', () => {
|
||||
it('renders a button for each step definition', () => {
|
||||
const wrapper = mountNav()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(STEP_DEFINITIONS.length)
|
||||
})
|
||||
|
||||
it('displays translated step titles', () => {
|
||||
const wrapper = mountNav()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toContain('Getting Started')
|
||||
expect(buttons[1].text()).toContain('Metadata')
|
||||
expect(buttons[2].text()).toContain('Preview')
|
||||
})
|
||||
|
||||
it('marks the current step button as aria-selected', () => {
|
||||
const wrapper = mountNav({ currentStep: 2 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].attributes('aria-selected')).toBe('false')
|
||||
expect(buttons[1].attributes('aria-selected')).toBe('true')
|
||||
expect(buttons[2].attributes('aria-selected')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows a check icon for completed steps', () => {
|
||||
const wrapper = mountNav({ currentStep: 3 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
|
||||
expect(buttons[1].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
|
||||
expect(buttons[2].find('i.icon-\\[lucide--check\\]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows step numbers for current and future steps', () => {
|
||||
const wrapper = mountNav({ currentStep: 2 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons[0].find('i.icon-\\[lucide--check\\]').exists()).toBe(true)
|
||||
expect(buttons[1].text()).toContain('2')
|
||||
expect(buttons[2].text()).toContain('3')
|
||||
})
|
||||
|
||||
it('emits update:currentStep when a step button is clicked', async () => {
|
||||
const wrapper = mountNav({ currentStep: 1 })
|
||||
await wrapper.findAll('button')[1].trigger('click')
|
||||
expect(wrapper.emitted('update:currentStep')).toEqual([[2]])
|
||||
})
|
||||
|
||||
it('renders separators between steps', () => {
|
||||
const wrapper = mountNav()
|
||||
const separators = wrapper.findAll('div.bg-border-default')
|
||||
expect(separators).toHaveLength(STEP_DEFINITIONS.length - 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<nav
|
||||
class="flex flex-col gap-1 px-4 py-2"
|
||||
role="tablist"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<template v-for="(step, index) in stepDefinitions" :key="step.number">
|
||||
<button
|
||||
role="tab"
|
||||
:aria-selected="step.number === currentStep"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm',
|
||||
'focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2',
|
||||
step.number === currentStep &&
|
||||
step.number === stepDefinitions.length &&
|
||||
'bg-blue-900 font-medium text-neutral',
|
||||
step.number === currentStep &&
|
||||
step.number < stepDefinitions.length &&
|
||||
'font-medium text-neutral',
|
||||
step.number < currentStep && 'bg-green-900 text-muted-foreground',
|
||||
step.number > currentStep && 'text-muted-foreground opacity-50'
|
||||
)
|
||||
"
|
||||
:disabled="step.number === stepDefinitions.length"
|
||||
@click="emit('update:currentStep', step.number)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs',
|
||||
step.number === currentStep &&
|
||||
'bg-comfy-accent text-comfy-accent-foreground',
|
||||
step.number < currentStep && 'bg-comfy-accent/20 text-neutral',
|
||||
step.number > currentStep &&
|
||||
'bg-secondary-background text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="step.number < currentStep"
|
||||
class="icon-[lucide--check] h-3.5 w-3.5"
|
||||
/>
|
||||
<span v-else>{{ step.number }}</span>
|
||||
</span>
|
||||
<span class="leading-tight">
|
||||
{{ t(step.titleKey)
|
||||
}}<template
|
||||
v-if="
|
||||
step.number === currentStep &&
|
||||
step.number === stepDefinitions.length
|
||||
"
|
||||
>
|
||||
🎉</template
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="index < stepDefinitions.length - 1"
|
||||
class="bg-border-default ml-5 h-4 w-px"
|
||||
/>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { PublishingStepDefinition } from './types'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
currentStep: number
|
||||
stepDefinitions: PublishingStepDefinition[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:currentStep': [step: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('templatePublishing.steps.categoryAndTagging.title') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.categoryAndTagging.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('templatePublishing.steps.complete.title') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.complete.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepperContext } from '../types'
|
||||
import { PublishingStepperKey } from '../types'
|
||||
import StepTemplatePublishingDescription from './StepTemplatePublishingDescription.vue'
|
||||
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((md: string) => `<p>${md}</p>`)
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
description: {
|
||||
title: 'Description',
|
||||
description: 'Write a detailed description of your template',
|
||||
editorLabel: 'Description (Markdown)',
|
||||
previewLabel: 'Description (Render preview)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(3)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingDescription, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context }
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingDescription', () => {
|
||||
it('renders editor and preview labels', () => {
|
||||
const { wrapper } = mountStep()
|
||||
expect(wrapper.text()).toContain('Description (Markdown)')
|
||||
expect(wrapper.text()).toContain('Description (Render preview)')
|
||||
})
|
||||
|
||||
it('renders a textarea for markdown editing', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('binds textarea to template.description', () => {
|
||||
const ctx = createContext({ description: 'Hello **world**' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect((textarea.element as HTMLTextAreaElement).value).toBe(
|
||||
'Hello **world**'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates template.description when textarea changes', async () => {
|
||||
const ctx = createContext({ description: '' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('New content')
|
||||
expect(ctx.template.value.description).toBe('New content')
|
||||
})
|
||||
|
||||
it('renders markdown preview from template.description', () => {
|
||||
const ctx = createContext({ description: 'Some markdown' })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const preview = wrapper.find('[class*="prose"]')
|
||||
expect(preview.html()).toContain('<p>Some markdown</p>')
|
||||
})
|
||||
|
||||
it('renders empty preview when description is undefined', () => {
|
||||
const ctx = createContext({})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
const preview = wrapper.find('[class*="prose"]')
|
||||
expect(preview.html()).toContain('<p></p>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-4 p-6">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<label for="tpl-description-editor" class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.description.editorLabel') }}
|
||||
</label>
|
||||
<textarea
|
||||
id="tpl-description-editor"
|
||||
v-model="ctx.template.value.description"
|
||||
class="min-h-0 flex-1 resize-none rounded-lg border border-border-default bg-secondary-background p-3 font-mono text-sm text-base-foreground focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.description.previewLabel') }}
|
||||
</span>
|
||||
<div
|
||||
class="prose prose-invert min-h-0 flex-1 overflow-y-auto rounded-lg border border-border-default bg-secondary-background p-3 text-sm scrollbar-custom"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
|
||||
const renderedHtml = computed(() =>
|
||||
renderMarkdownToHtml(ctx.template.value.description ?? '')
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('templatePublishing.steps.landing.title') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.landing.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:valid': [valid: boolean]
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:valid', true)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,234 @@
|
||||
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 StepTemplatePublishingMetadata from './StepTemplatePublishingMetadata.vue'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templatePublishing: {
|
||||
steps: {
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
description: 'Title, description, and author info',
|
||||
titleLabel: 'Title',
|
||||
categoryLabel: 'Categories',
|
||||
tagsLabel: 'Tags',
|
||||
tagsPlaceholder: 'Type to search tags…',
|
||||
difficultyLabel: 'Difficulty',
|
||||
licenseLabel: 'License',
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createContext(
|
||||
templateData: Partial<MarketplaceTemplate> = {}
|
||||
): PublishingStepperContext {
|
||||
const template = ref<Partial<MarketplaceTemplate>>(templateData)
|
||||
const currentStep = ref(2)
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: 8,
|
||||
isFirstStep: computed(() => currentStep.value === 1),
|
||||
isLastStep: computed(() => currentStep.value === 8),
|
||||
canProceed: computed(() => false),
|
||||
template,
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
setStepValid: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mountStep(ctx?: PublishingStepperContext) {
|
||||
const context = ctx ?? createContext()
|
||||
return {
|
||||
wrapper: mount(StepTemplatePublishingMetadata, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { [PublishingStepperKey as symbol]: context },
|
||||
stubs: {
|
||||
FormItem: {
|
||||
template:
|
||||
'<div :data-testid="`form-item-${id}`"><input :value="formValue" @input="$emit(\'update:formValue\', $event.target.value)" /></div>',
|
||||
props: ['item', 'id', 'formValue', 'labelClass'],
|
||||
emits: ['update:formValue']
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
ctx: context
|
||||
}
|
||||
}
|
||||
|
||||
describe('StepTemplatePublishingMetadata', () => {
|
||||
it('renders heading and all form fields', () => {
|
||||
const { wrapper } = mountStep()
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe('Metadata')
|
||||
expect(wrapper.find('[data-testid="form-item-tpl-title"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.text()).toContain('Difficulty')
|
||||
expect(wrapper.find('[data-testid="form-item-tpl-license"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.text()).toContain('Categories')
|
||||
expect(wrapper.text()).toContain('Tags')
|
||||
})
|
||||
|
||||
it('renders all category checkboxes', () => {
|
||||
const { wrapper } = mountStep()
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(10)
|
||||
expect(wrapper.text()).toContain('Image Generation')
|
||||
expect(wrapper.text()).toContain('ControlNet')
|
||||
})
|
||||
|
||||
it('toggles category when checkbox is clicked', async () => {
|
||||
const ctx = createContext({ categories: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const checkbox = wrapper.find('#tpl-category-audio')
|
||||
await checkbox.setValue(true)
|
||||
|
||||
expect(ctx.template.value.categories).toContain('audio')
|
||||
|
||||
await checkbox.setValue(false)
|
||||
expect(ctx.template.value.categories).not.toContain('audio')
|
||||
})
|
||||
|
||||
it('preserves existing categories when toggling', async () => {
|
||||
const ctx = createContext({ categories: ['text', '3d'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const audioCheckbox = wrapper.find('#tpl-category-audio')
|
||||
await audioCheckbox.setValue(true)
|
||||
|
||||
expect(ctx.template.value.categories).toContain('text')
|
||||
expect(ctx.template.value.categories).toContain('3d')
|
||||
expect(ctx.template.value.categories).toContain('audio')
|
||||
})
|
||||
|
||||
it('adds a tag when pressing enter in the tags input', async () => {
|
||||
const ctx = createContext({ tags: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('my-tag')
|
||||
await tagInput.trigger('keydown.enter')
|
||||
|
||||
expect(ctx.template.value.tags).toContain('my-tag')
|
||||
})
|
||||
|
||||
it('does not add duplicate tags', async () => {
|
||||
const ctx = createContext({ tags: ['existing'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('existing')
|
||||
await tagInput.trigger('keydown.enter')
|
||||
|
||||
expect(ctx.template.value.tags).toEqual(['existing'])
|
||||
})
|
||||
|
||||
it('removes a tag when the remove button is clicked', async () => {
|
||||
const ctx = createContext({ tags: ['alpha', 'beta'] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const removeButtons = wrapper.findAll('button[aria-label^="Remove tag"]')
|
||||
await removeButtons[0].trigger('click')
|
||||
|
||||
expect(ctx.template.value.tags).toEqual(['beta'])
|
||||
})
|
||||
|
||||
it('shows filtered suggestions when typing in tags input', async () => {
|
||||
const ctx = createContext({ tags: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('flux')
|
||||
await tagInput.trigger('focus')
|
||||
|
||||
const suggestions = wrapper.findAll('li')
|
||||
expect(suggestions.length).toBeGreaterThan(0)
|
||||
expect(suggestions[0].text()).toBe('flux')
|
||||
})
|
||||
|
||||
it('adds a suggestion tag when clicking it', async () => {
|
||||
const ctx = createContext({ tags: [] })
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const tagInput = wrapper.find('input[type="text"]')
|
||||
await tagInput.setValue('flux')
|
||||
await tagInput.trigger('focus')
|
||||
|
||||
const suggestion = wrapper.find('li')
|
||||
await suggestion.trigger('mousedown')
|
||||
|
||||
expect(ctx.template.value.tags).toContain('flux')
|
||||
})
|
||||
|
||||
it('selects difficulty when radio button is clicked', async () => {
|
||||
const ctx = createContext({})
|
||||
const { wrapper } = mountStep(ctx)
|
||||
|
||||
const intermediateRadio = wrapper.find('#tpl-difficulty-intermediate')
|
||||
await intermediateRadio.setValue(true)
|
||||
|
||||
expect(ctx.template.value.difficulty).toBe('intermediate')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('templatePublishing.steps.metadata.title') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.metadata.description') }}
|
||||
</p>
|
||||
|
||||
<FormItem
|
||||
id="tpl-title"
|
||||
v-model:form-value="ctx.template.value.title"
|
||||
:item="titleField"
|
||||
/>
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex grow items-center">
|
||||
<span id="tpl-category-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.categoryLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-labelledby="tpl-category-label"
|
||||
>
|
||||
<label
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat.value"
|
||||
:for="`tpl-category-${cat.value}`"
|
||||
class="flex cursor-pointer items-center gap-1.5 text-sm"
|
||||
>
|
||||
<input
|
||||
:id="`tpl-category-${cat.value}`"
|
||||
type="checkbox"
|
||||
:checked="ctx.template.value.categories?.includes(cat.value)"
|
||||
@change="toggleCategory(cat.value)"
|
||||
/>
|
||||
{{ t(`templatePublishing.steps.metadata.category.${cat.key}`) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex grow items-center">
|
||||
<span id="tpl-tags-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.tagsLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="tagQuery"
|
||||
type="text"
|
||||
class="h-8 w-44 rounded border border-border-default bg-transparent px-2 text-sm focus:outline-none"
|
||||
:placeholder="
|
||||
t('templatePublishing.steps.metadata.tagsPlaceholder')
|
||||
"
|
||||
aria-labelledby="tpl-tags-label"
|
||||
@focus="showSuggestions = true"
|
||||
@keydown.enter.prevent="addTag(tagQuery)"
|
||||
/>
|
||||
<ul
|
||||
v-if="showSuggestions && filteredSuggestions.length > 0"
|
||||
class="absolute z-10 mt-1 max-h-40 w-44 overflow-auto rounded border border-border-default bg-comfy-menu-background shadow-md"
|
||||
>
|
||||
<li
|
||||
v-for="suggestion in filteredSuggestions"
|
||||
:key="suggestion"
|
||||
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
|
||||
@mousedown.prevent="addTag(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="ctx.template.value.tags?.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<span
|
||||
v-for="tag in ctx.template.value.tags"
|
||||
:key="tag"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-danger"
|
||||
:aria-label="`Remove tag ${tag}`"
|
||||
@click="removeTag(tag)"
|
||||
>
|
||||
<i class="icon-[lucide--x] h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex grow items-center">
|
||||
<span id="tpl-difficulty-label" class="text-muted">
|
||||
{{ t('templatePublishing.steps.metadata.difficultyLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row gap-4"
|
||||
role="radiogroup"
|
||||
aria-labelledby="tpl-difficulty-label"
|
||||
>
|
||||
<label
|
||||
v-for="option in DIFFICULTY_OPTIONS"
|
||||
:key="option.value"
|
||||
:for="`tpl-difficulty-${option.value}`"
|
||||
class="flex cursor-pointer items-center gap-1.5 text-sm"
|
||||
>
|
||||
<input
|
||||
:id="`tpl-difficulty-${option.value}`"
|
||||
type="radio"
|
||||
name="tpl-difficulty"
|
||||
:value="option.value"
|
||||
:checked="ctx.template.value.difficulty === option.value"
|
||||
:class="
|
||||
cn(
|
||||
'h-5 w-5 appearance-none rounded-full border checked:bg-current checked:shadow-[inset_0_0_0_1px_white]',
|
||||
option.borderClass
|
||||
)
|
||||
"
|
||||
@change="ctx.template.value.difficulty = option.value"
|
||||
/>
|
||||
{{ option.text }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormItem
|
||||
id="tpl-license"
|
||||
v-model:form-value="ctx.template.value.license"
|
||||
:item="licenseField"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormItem from '@/components/common/FormItem.vue'
|
||||
import type { FormItem as FormItemType } from '@/platform/settings/types'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { PublishingStepperKey } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const ctx = inject(PublishingStepperKey)!
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'imageGeneration', value: 'image-generation' },
|
||||
{ key: 'videoGeneration', value: 'video-generation' },
|
||||
{ key: 'audio', value: 'audio' },
|
||||
{ key: 'text', value: 'text' },
|
||||
{ key: 'threeD', value: '3d' },
|
||||
{ key: 'upscaling', value: 'upscaling' },
|
||||
{ key: 'inpainting', value: 'inpainting' },
|
||||
{ key: 'controlNet', value: 'controlnet' },
|
||||
{ key: 'styleTransfer', value: 'style-transfer' },
|
||||
{ key: 'other', value: 'other' }
|
||||
] as const
|
||||
|
||||
const TAG_SUGGESTIONS = [
|
||||
'stable-diffusion',
|
||||
'flux',
|
||||
'sdxl',
|
||||
'sd1.5',
|
||||
'img2img',
|
||||
'txt2img',
|
||||
'upscale',
|
||||
'face-restore',
|
||||
'animation',
|
||||
'video',
|
||||
'lora',
|
||||
'controlnet',
|
||||
'ipadapter',
|
||||
'inpainting',
|
||||
'outpainting',
|
||||
'depth',
|
||||
'pose',
|
||||
'segmentation',
|
||||
'latent',
|
||||
'sampler'
|
||||
]
|
||||
|
||||
const titleField: FormItemType = {
|
||||
name: t('templatePublishing.steps.metadata.titleLabel'),
|
||||
type: 'text'
|
||||
}
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.difficulty.beginner'),
|
||||
value: 'beginner' as const,
|
||||
borderClass: 'border-green-400'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.difficulty.intermediate'),
|
||||
value: 'intermediate' as const,
|
||||
borderClass: 'border-amber-400'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.difficulty.advanced'),
|
||||
value: 'advanced' as const,
|
||||
borderClass: 'border-red-400'
|
||||
}
|
||||
]
|
||||
|
||||
const licenseField: FormItemType = {
|
||||
name: t('templatePublishing.steps.metadata.licenseLabel'),
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ text: t('templatePublishing.steps.metadata.license.mit'), value: 'mit' },
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.ccBy'),
|
||||
value: 'cc-by'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.ccBySa'),
|
||||
value: 'cc-by-sa'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.ccByNc'),
|
||||
value: 'cc-by-nc'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.apache'),
|
||||
value: 'apache'
|
||||
},
|
||||
{
|
||||
text: t('templatePublishing.steps.metadata.license.custom'),
|
||||
value: 'custom'
|
||||
}
|
||||
],
|
||||
attrs: { filter: true }
|
||||
}
|
||||
|
||||
const tagQuery = ref('')
|
||||
const showSuggestions = ref(false)
|
||||
|
||||
const filteredSuggestions = computed(() => {
|
||||
const query = tagQuery.value.toLowerCase().trim()
|
||||
if (!query) return []
|
||||
const existing = ctx.template.value.tags ?? []
|
||||
return TAG_SUGGESTIONS.filter(
|
||||
(s) => s.includes(query) && !existing.includes(s)
|
||||
)
|
||||
})
|
||||
|
||||
function toggleCategory(value: string) {
|
||||
const categories = ctx.template.value.categories ?? []
|
||||
const index = categories.indexOf(value)
|
||||
if (index >= 0) {
|
||||
categories.splice(index, 1)
|
||||
} else {
|
||||
categories.push(value)
|
||||
}
|
||||
ctx.template.value.categories = [...categories]
|
||||
}
|
||||
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim().toLowerCase()
|
||||
if (!trimmed) return
|
||||
const tags = ctx.template.value.tags ?? []
|
||||
if (!tags.includes(trimmed)) {
|
||||
ctx.template.value.tags = [...tags, trimmed]
|
||||
}
|
||||
tagQuery.value = ''
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
const tags = ctx.template.value.tags ?? []
|
||||
ctx.template.value.tags = tags.filter((t) => t !== tag)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => ctx.template.value,
|
||||
() => ctx.saveDraft(),
|
||||
{ deep: true, debounce: 500 }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('templatePublishing.steps.preview.title') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.preview.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('templatePublishing.steps.previewGeneration.title') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.previewGeneration.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('templatePublishing.steps.submissionForReview.title') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templatePublishing.steps.submissionForReview.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
83
src/components/templatePublishing/types.ts
Normal file
83
src/components/templatePublishing/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
/**
|
||||
* Definition of a single step in the template publishing wizard.
|
||||
*/
|
||||
export interface PublishingStepDefinition {
|
||||
/** 1-indexed step number */
|
||||
number: number
|
||||
/** i18n key for the step's display title */
|
||||
titleKey: string
|
||||
/** i18n key for the step's short description */
|
||||
descriptionKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Context shared between the publishing dialog and its step panels
|
||||
* via provide/inject.
|
||||
*/
|
||||
export interface PublishingStepperContext {
|
||||
currentStep: Readonly<Ref<number>>
|
||||
totalSteps: number
|
||||
isFirstStep: Readonly<Ref<boolean>>
|
||||
isLastStep: Readonly<Ref<boolean>>
|
||||
canProceed: Readonly<Ref<boolean>>
|
||||
template: Ref<Partial<MarketplaceTemplate>>
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
goToStep: (step: number) => void
|
||||
saveDraft: () => void
|
||||
setStepValid: (step: number, valid: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection key for the publishing stepper context, allowing step panel
|
||||
* components to access shared navigation and draft state.
|
||||
*/
|
||||
export const PublishingStepperKey: InjectionKey<PublishingStepperContext> =
|
||||
Symbol('PublishingStepperContext')
|
||||
|
||||
export const PUBLISHING_STEP_DEFINITIONS: PublishingStepDefinition[] = [
|
||||
{
|
||||
number: 1,
|
||||
titleKey: 'templatePublishing.steps.landing.title',
|
||||
descriptionKey: 'templatePublishing.steps.landing.description'
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
titleKey: 'templatePublishing.steps.metadata.title',
|
||||
descriptionKey: 'templatePublishing.steps.metadata.description'
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
titleKey: 'templatePublishing.steps.description.title',
|
||||
descriptionKey: 'templatePublishing.steps.description.description'
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
titleKey: 'templatePublishing.steps.previewGeneration.title',
|
||||
descriptionKey: 'templatePublishing.steps.previewGeneration.description'
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
titleKey: 'templatePublishing.steps.categoryAndTagging.title',
|
||||
descriptionKey: 'templatePublishing.steps.categoryAndTagging.description'
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
titleKey: 'templatePublishing.steps.preview.title',
|
||||
descriptionKey: 'templatePublishing.steps.preview.description'
|
||||
},
|
||||
{
|
||||
number: 7,
|
||||
titleKey: 'templatePublishing.steps.submissionForReview.title',
|
||||
descriptionKey: 'templatePublishing.steps.submissionForReview.description'
|
||||
},
|
||||
{
|
||||
number: 8,
|
||||
titleKey: 'templatePublishing.steps.complete.title',
|
||||
descriptionKey: 'templatePublishing.steps.complete.description'
|
||||
}
|
||||
]
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
|
||||
import { useTemplateMarketplaceDialog } from './useTemplateMarketplaceDialog'
|
||||
import { useTemplatePublishingDialog } from './useTemplatePublishingDialog'
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -348,11 +348,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
},
|
||||
{
|
||||
// comeback
|
||||
id: 'Comfy.ShowTemplateMarketplace',
|
||||
id: 'Comfy.ShowTemplatePublishing',
|
||||
icon: 'pi pi-objects-column',
|
||||
label: 'Show Template Marketplace',
|
||||
label: 'Show Template Publishing',
|
||||
function: () => {
|
||||
useTemplateMarketplaceDialog().show()
|
||||
useTemplatePublishingDialog().show()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
91
src/composables/useTemplatePublishingDialog.test.ts
Normal file
91
src/composables/useTemplatePublishingDialog.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDialogService = vi.hoisted(() => ({
|
||||
showLayoutDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templatePublishing/TemplatePublishingDialog.vue', () => ({
|
||||
default: { name: 'MockTemplatePublishingDialog' }
|
||||
}))
|
||||
|
||||
import { useTemplatePublishingDialog } from './useTemplatePublishingDialog'
|
||||
|
||||
describe('useTemplatePublishingDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('show', () => {
|
||||
it('opens the dialog via dialogService', () => {
|
||||
const { show } = useTemplatePublishingDialog()
|
||||
show()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'global-template-publishing'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes initialPage to the dialog component', () => {
|
||||
const { show } = useTemplatePublishingDialog()
|
||||
show({ initialPage: 'metadata' })
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
initialPage: 'metadata'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes undefined initialPage when no options given', () => {
|
||||
const { show } = useTemplatePublishingDialog()
|
||||
show()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
initialPage: undefined
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('provides an onClose callback that closes the dialog', () => {
|
||||
const { show } = useTemplatePublishingDialog()
|
||||
show()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onClose()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'global-template-publishing'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hide', () => {
|
||||
it('closes the dialog via dialogStore', () => {
|
||||
const { hide } = useTemplatePublishingDialog()
|
||||
hide()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'global-template-publishing'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,15 @@
|
||||
import { h } from 'vue'
|
||||
|
||||
import TemplatePublishingDialog from '@/components/templatePublishing/TemplatePublishingDialog.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const DIALOG_KEY = 'global-workflow-template-selector'
|
||||
// const GETTING_STARTED_CATEGORY_ID = 'basics-getting-started' // comeback when there are tabs to pick between, or remove if they're fundamentally ordered
|
||||
const DIALOG_KEY = 'global-template-publishing'
|
||||
|
||||
export const useTemplateMarketplaceDialog = () => {
|
||||
/**
|
||||
* Manages the lifecycle of the template publishing dialog.
|
||||
*
|
||||
* @returns `show` to open the dialog and `hide` to close it.
|
||||
*/
|
||||
export const useTemplatePublishingDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
@@ -16,12 +19,11 @@ export const useTemplateMarketplaceDialog = () => {
|
||||
|
||||
function show(options?: { initialPage?: string }) {
|
||||
// comeback need a new telemetry for this
|
||||
// useTelemetry()?.trackTemplateLibraryOpened({ source })
|
||||
// useTelemetry()?.trackTemplatePublishingOpened({ source })
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: () => h('div', 'Placeholder comeback'),
|
||||
// component: TemplateMarketplaceDialog,
|
||||
component: TemplatePublishingDialog,
|
||||
props: {
|
||||
onClose: hide,
|
||||
initialPage: options?.initialPage
|
||||
131
src/composables/useTemplatePublishingStepper.test.ts
Normal file
131
src/composables/useTemplatePublishingStepper.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockLoad, mockSave } = vi.hoisted(() => ({
|
||||
mockLoad: vi.fn(),
|
||||
mockSave: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplatePublishStorage',
|
||||
() => ({
|
||||
loadTemplateUnderway: mockLoad,
|
||||
saveTemplateUnderway: mockSave
|
||||
})
|
||||
)
|
||||
|
||||
import { useTemplatePublishingStepper } from './useTemplatePublishingStepper'
|
||||
|
||||
describe('useTemplatePublishingStepper', () => {
|
||||
it('starts at step 1 by default', () => {
|
||||
const { currentStep } = useTemplatePublishingStepper()
|
||||
expect(currentStep.value).toBe(1)
|
||||
})
|
||||
|
||||
it('starts at the given initialStep', () => {
|
||||
const { currentStep } = useTemplatePublishingStepper({ initialStep: 4 })
|
||||
expect(currentStep.value).toBe(4)
|
||||
})
|
||||
|
||||
it('clamps initialStep to valid range', () => {
|
||||
const low = useTemplatePublishingStepper({ initialStep: 0 })
|
||||
expect(low.currentStep.value).toBe(1)
|
||||
|
||||
const high = useTemplatePublishingStepper({ initialStep: 99 })
|
||||
expect(high.currentStep.value).toBe(high.totalSteps)
|
||||
})
|
||||
|
||||
it('nextStep advances by one', () => {
|
||||
const { currentStep, nextStep } = useTemplatePublishingStepper()
|
||||
nextStep()
|
||||
expect(currentStep.value).toBe(2)
|
||||
})
|
||||
|
||||
it('nextStep does not exceed totalSteps', () => {
|
||||
const { currentStep, nextStep, totalSteps } = useTemplatePublishingStepper({
|
||||
initialStep: 8
|
||||
})
|
||||
nextStep()
|
||||
expect(currentStep.value).toBe(totalSteps)
|
||||
})
|
||||
|
||||
it('prevStep goes back by one', () => {
|
||||
const { currentStep, prevStep } = useTemplatePublishingStepper({
|
||||
initialStep: 3
|
||||
})
|
||||
prevStep()
|
||||
expect(currentStep.value).toBe(2)
|
||||
})
|
||||
|
||||
it('prevStep does not go below 1', () => {
|
||||
const { currentStep, prevStep } = useTemplatePublishingStepper()
|
||||
prevStep()
|
||||
expect(currentStep.value).toBe(1)
|
||||
})
|
||||
|
||||
it('goToStep navigates to the given step', () => {
|
||||
const { currentStep, goToStep } = useTemplatePublishingStepper()
|
||||
goToStep(5)
|
||||
expect(currentStep.value).toBe(5)
|
||||
})
|
||||
|
||||
it('goToStep clamps out-of-range values', () => {
|
||||
const { currentStep, goToStep, totalSteps } = useTemplatePublishingStepper()
|
||||
goToStep(100)
|
||||
expect(currentStep.value).toBe(totalSteps)
|
||||
goToStep(-1)
|
||||
expect(currentStep.value).toBe(1)
|
||||
})
|
||||
|
||||
it('isFirstStep and isLastStep reflect current position', () => {
|
||||
const { isFirstStep, isLastStep, nextStep, goToStep, totalSteps } =
|
||||
useTemplatePublishingStepper()
|
||||
|
||||
expect(isFirstStep.value).toBe(true)
|
||||
expect(isLastStep.value).toBe(false)
|
||||
|
||||
nextStep()
|
||||
expect(isFirstStep.value).toBe(false)
|
||||
|
||||
goToStep(totalSteps)
|
||||
expect(isLastStep.value).toBe(true)
|
||||
})
|
||||
|
||||
it('canProceed reflects step validity', () => {
|
||||
const { canProceed, setStepValid } = useTemplatePublishingStepper()
|
||||
expect(canProceed.value).toBe(false)
|
||||
|
||||
setStepValid(1, true)
|
||||
expect(canProceed.value).toBe(true)
|
||||
|
||||
setStepValid(1, false)
|
||||
expect(canProceed.value).toBe(false)
|
||||
})
|
||||
|
||||
it('saveDraft delegates to saveTemplateUnderway', () => {
|
||||
const { template, saveDraft } = useTemplatePublishingStepper()
|
||||
template.value = { title: 'Test Template' }
|
||||
saveDraft()
|
||||
expect(mockSave).toHaveBeenCalledWith({ title: 'Test Template' })
|
||||
})
|
||||
|
||||
it('loads existing draft on initialisation', () => {
|
||||
const draft = { title: 'Saved Draft', description: 'A draft' }
|
||||
mockLoad.mockReturnValueOnce(draft)
|
||||
|
||||
const { template } = useTemplatePublishingStepper()
|
||||
expect(template.value).toEqual(draft)
|
||||
})
|
||||
|
||||
it('uses empty object when no draft is stored', () => {
|
||||
mockLoad.mockReturnValueOnce(null)
|
||||
|
||||
const { template } = useTemplatePublishingStepper()
|
||||
expect(template.value).toEqual({})
|
||||
})
|
||||
|
||||
it('exposes the correct number of step definitions', () => {
|
||||
const { stepDefinitions, totalSteps } = useTemplatePublishingStepper()
|
||||
expect(stepDefinitions).toHaveLength(totalSteps)
|
||||
expect(totalSteps).toBe(8)
|
||||
})
|
||||
})
|
||||
85
src/composables/useTemplatePublishingStepper.ts
Normal file
85
src/composables/useTemplatePublishingStepper.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/types/templateMarketplace'
|
||||
|
||||
import type { PublishingStepDefinition } from '@/components/templatePublishing/types'
|
||||
import { PUBLISHING_STEP_DEFINITIONS } from '@/components/templatePublishing/types'
|
||||
import {
|
||||
loadTemplateUnderway,
|
||||
saveTemplateUnderway
|
||||
} from '@/platform/workflow/templates/composables/useTemplatePublishStorage'
|
||||
|
||||
/**
|
||||
* Manages the state and navigation logic for the template publishing
|
||||
* wizard.
|
||||
*
|
||||
* Owns the current step, per-step validity tracking, and draft
|
||||
* persistence via {@link saveTemplateUnderway}/{@link loadTemplateUnderway}.
|
||||
*
|
||||
* @param options.initialStep - 1-indexed step to start on (defaults to 1)
|
||||
*/
|
||||
export function useTemplatePublishingStepper(options?: {
|
||||
initialStep?: number
|
||||
}) {
|
||||
const totalSteps = PUBLISHING_STEP_DEFINITIONS.length
|
||||
|
||||
const currentStep = ref(clampStep(options?.initialStep ?? 1, totalSteps))
|
||||
|
||||
const template = ref<Partial<MarketplaceTemplate>>(
|
||||
loadTemplateUnderway() ?? {}
|
||||
)
|
||||
|
||||
const stepValidity = ref<Record<number, boolean>>({})
|
||||
|
||||
const stepDefinitions: PublishingStepDefinition[] =
|
||||
PUBLISHING_STEP_DEFINITIONS
|
||||
|
||||
const isFirstStep = computed(() => currentStep.value === 1)
|
||||
const isLastStep = computed(() => currentStep.value === totalSteps)
|
||||
const canProceed = computed(
|
||||
() => stepValidity.value[currentStep.value] === true
|
||||
)
|
||||
|
||||
function goToStep(step: number) {
|
||||
currentStep.value = clampStep(step, totalSteps)
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (!isLastStep.value) {
|
||||
currentStep.value = clampStep(currentStep.value + 1, totalSteps)
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (!isFirstStep.value) {
|
||||
currentStep.value = clampStep(currentStep.value - 1, totalSteps)
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
saveTemplateUnderway(template.value)
|
||||
}
|
||||
|
||||
function setStepValid(step: number, valid: boolean) {
|
||||
stepValidity.value[step] = valid
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
template,
|
||||
stepDefinitions,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canProceed,
|
||||
goToStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
saveDraft,
|
||||
setStepValid
|
||||
}
|
||||
}
|
||||
|
||||
function clampStep(step: number, max: number): number {
|
||||
return Math.max(1, Math.min(step, max))
|
||||
}
|
||||
@@ -198,10 +198,10 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
label: t('menuLabels.templateMarketplace'),
|
||||
label: t('menuLabels.templatePublishing'),
|
||||
icon: 'pi pi-objects-column',
|
||||
command: async () => {
|
||||
await commandStore.execute('Comfy.OpenTemplateMarketplace')
|
||||
await commandStore.execute('Comfy.ShowTemplatePublishing')
|
||||
},
|
||||
visible: isRoot && flags.templateMarketplaceEnabled,
|
||||
prependSeparator: true
|
||||
|
||||
@@ -1054,10 +1054,82 @@
|
||||
"enterFilenamePrompt": "Enter the filename:",
|
||||
"saveWorkflow": "Save workflow"
|
||||
},
|
||||
"templateMarketplace": {
|
||||
"templatePublishing": {
|
||||
"publishToMarketplace": "Publish to Marketplace",
|
||||
"saveDraft": "Save Draft",
|
||||
"previewThenSave": "Preview then Save"
|
||||
"previewThenSave": "Preview then Save",
|
||||
"dialogTitle": "Template Publishing",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"submit": "Submit for Review",
|
||||
"stepProgress": "Step {current} of {total}",
|
||||
"steps": {
|
||||
"landing": {
|
||||
"title": "Getting Started",
|
||||
"description": "Overview of the publishing process"
|
||||
},
|
||||
"metadata": {
|
||||
"title": "Metadata",
|
||||
"description": "Title, description, and author info",
|
||||
"titleLabel": "Title",
|
||||
"categoryLabel": "Categories",
|
||||
"tagsLabel": "Tags",
|
||||
"tagsPlaceholder": "Type to search tags…",
|
||||
"difficultyLabel": "Difficulty",
|
||||
"licenseLabel": "License",
|
||||
"difficulty": {
|
||||
"beginner": "Beginner",
|
||||
"intermediate": "Intermediate",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"license": {
|
||||
"mit": "MIT",
|
||||
"ccBy": "CC BY",
|
||||
"ccBySa": "CC BY-SA",
|
||||
"ccByNc": "CC BY-NC",
|
||||
"apache": "Apache",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"category": {
|
||||
"imageGeneration": "Image Generation",
|
||||
"videoGeneration": "Video Generation",
|
||||
"audio": "Audio",
|
||||
"text": "Text",
|
||||
"threeD": "3D",
|
||||
"upscaling": "Upscaling",
|
||||
"inpainting": "Inpainting",
|
||||
"controlNet": "ControlNet",
|
||||
"styleTransfer": "Style Transfer",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"description": "Write a detailed description of your template",
|
||||
"editorLabel": "Description (Markdown)",
|
||||
"previewLabel": "Description (Render preview)"
|
||||
},
|
||||
"previewGeneration": {
|
||||
"title": "Preview",
|
||||
"description": "Generate preview images and videos"
|
||||
},
|
||||
"categoryAndTagging": {
|
||||
"title": "Categories & Tags",
|
||||
"description": "Categorize and tag your template"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"description": "Review your template before submitting"
|
||||
},
|
||||
"submissionForReview": {
|
||||
"title": "Submit",
|
||||
"description": "Submit your template for review"
|
||||
},
|
||||
"complete": {
|
||||
"title": "Complete",
|
||||
"description": "Your template has been submitted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subgraphStore": {
|
||||
"confirmDeleteTitle": "Delete blueprint?",
|
||||
@@ -1333,7 +1405,7 @@
|
||||
"Model Library": "Model Library",
|
||||
"Node Library": "Node Library",
|
||||
"Workflows": "Workflows",
|
||||
"templateMarketplace": "Template Marketplace"
|
||||
"templatePublishing": "Template Publishing"
|
||||
},
|
||||
"desktopMenu": {
|
||||
"reinstall": "Reinstall",
|
||||
|
||||
Reference in New Issue
Block a user