Compare commits

..

9 Commits

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

Surfaces the estimate in three places:

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

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

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

Bumps version to 1.46.0.

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

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

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

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

Bump version to 1.45.0.

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

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

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

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

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

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

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

Bump version to 1.42.0.

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

View File

@@ -53,13 +53,7 @@ jobs:
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
run: |
pnpm install --frozen-lockfile
# Desktop-specific release artifact with desktop distribution flags.
DISTRIBUTION=desktop pnpm build
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
NX_SKIP_NX_CACHE=true pnpm build
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6
@@ -68,7 +62,6 @@ jobs:
path: |
dist/
dist.zip
dist-desktop.zip
draft_release:
needs: build
@@ -86,7 +79,6 @@ jobs:
with:
files: |
dist.zip
dist-desktop.zip
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: >-

View File

@@ -40,12 +40,12 @@
"block-no-empty": true,
"no-descending-specificity": null,
"no-duplicate-at-import-rules": true,
"at-rule-disallowed-list": ["apply"],
"at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"tailwind",
"apply",
"layer",
"config",
"theme",

View File

@@ -61,7 +61,8 @@
"^build"
],
"options": {
"command": "vite build --config apps/desktop-ui/vite.config.mts"
"cwd": "apps/desktop-ui",
"command": "vite build --config vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"

View File

@@ -4,39 +4,3 @@
position: absolute;
inset: 0;
}
.p-button-secondary {
border: none;
background-color: var(--color-neutral-600);
color: var(--color-white);
}
.p-button-secondary:hover {
background-color: var(--color-neutral-550);
}
.p-button-secondary:active {
background-color: var(--color-neutral-500);
}
.p-button-danger {
background-color: var(--color-coral-red-600);
}
.p-button-danger:hover {
background-color: var(--color-coral-red-500);
}
.p-button-danger:active {
background-color: var(--color-coral-red-400);
}
.task-div .p-card {
transition: opacity var(--default-transition-duration);
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
}
.task-div .p-card:hover {
opacity: 1;
}

View File

@@ -101,15 +101,13 @@ onUnmounted(() => {
</script>
<style scoped>
/* xterm renders its internal DOM outside Vue templates, so :deep selectors are
* required to style those generated nodes.
*/
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm {
overflow: hidden;
@apply overflow-hidden;
}
:deep(.p-terminal) .xterm-screen {
overflow: hidden;
background-color: var(--color-neutral-900);
@apply bg-neutral-900 overflow-hidden;
}
</style>

View File

@@ -7,7 +7,7 @@
option-value="value"
:disabled="isSwitching"
:pt="dropdownPt"
:size="size"
:size="props.size"
class="language-selector"
@change="onLocaleChange"
>
@@ -36,10 +36,16 @@ import { i18n, loadLocale, st } from '@/i18n'
type VariantKey = 'dark' | 'light'
type SizeKey = 'small' | 'large'
const { variant = 'dark', size = 'small' } = defineProps<{
variant?: VariantKey
size?: SizeKey
}>()
const props = withDefaults(
defineProps<{
variant?: VariantKey
size?: SizeKey
}>(),
{
variant: 'dark',
size: 'small'
}
)
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
@@ -98,8 +104,10 @@ const VARIANT_PRESETS = {
const selectedLocale = ref<string>(i18n.global.locale.value)
const isSwitching = ref(false)
const sizePreset = computed(() => SIZE_PRESETS[size])
const variantPreset = computed(() => VARIANT_PRESETS[variant])
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
const variantPreset = computed(
() => VARIANT_PRESETS[props.variant as VariantKey]
)
const dropdownPt = computed(() => ({
root: {
@@ -187,17 +195,13 @@ async function onLocaleChange(event: SelectChangeEvent) {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-dropdown-panel .p-dropdown-item) {
transition-property: color, background-color, border-color;
transition-duration: var(--default-transition-duration);
@apply transition-colors;
}
:deep(.p-dropdown) {
&:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--color-neutral-900),
0 0 0 4px color-mix(in srgb, var(--color-brand-yellow) 60%, transparent);
}
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
}
</style>

View File

@@ -269,43 +269,26 @@ const onFocus = async () => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.location-picker-accordion) {
padding-inline: calc(var(--spacing) * 12);
@apply px-12;
.p-accordionpanel {
border: 0;
background-color: transparent;
@apply border-0 bg-transparent;
}
.p-accordionheader {
margin-top: calc(var(--spacing) * 2);
border: 0;
border-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
&:hover {
background-color: color-mix(
in srgb,
var(--color-neutral-700) 50%,
transparent
);
}
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
border-top-left-radius: var(--radius-xl);
border-top-right-radius: var(--radius-xl);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
@apply rounded-t-xl rounded-b-none;
}
.p-accordionheader-toggle-icon {
@@ -316,24 +299,11 @@ const onFocus = async () => {
}
.p-accordioncontent {
border: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-right-radius: var(--radius-xl);
border-bottom-left-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
}
.p-accordioncontent-content {
background-color: transparent;
padding-top: calc(var(--spacing) * 3);
padding-right: calc(var(--spacing) * 5);
padding-bottom: calc(var(--spacing) * 5);
padding-left: calc(var(--spacing) * 5);
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
}
/* Override default chevron icons to use up/down */

View File

@@ -1,20 +1,11 @@
<template>
<div
:class="
cn(
'task-div group/task-card relative grid min-h-52 max-w-48',
isLoading && 'opacity-75'
)
"
class="task-div relative grid min-h-52 max-w-48"
:class="{ 'opacity-75': isLoading }"
>
<Card
:class="
cn(
'relative h-full max-w-48 overflow-hidden',
runner.state !== 'error' && 'opacity-65'
)
"
:pt="cardPt"
class="relative h-full max-w-48 overflow-hidden"
:class="{ 'opacity-65': runner.state !== 'error' }"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
@@ -52,7 +43,7 @@
<i
v-if="!isLoading && runner.state === 'OK'"
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
class="task-card-ok pi pi-check"
/>
</div>
</template>
@@ -64,7 +55,6 @@ import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { cn } from '@/utils/tailwindUtil'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()
@@ -93,9 +83,51 @@ const reactiveExecuting = computed(() => !!runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
const cardPt = {
header: { class: 'z-0' },
body: { class: 'z-[1] grow justify-between' }
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
.task-card-ok {
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
font-size: 4rem;
text-shadow: 0.25rem 0 0.5rem black;
z-index: 10;
}
.p-card {
@apply transition-opacity;
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
&.opacity-65 {
opacity: 0.4;
}
&:hover {
opacity: 1;
}
}
:deep(.p-card-header) {
z-index: 0;
}
:deep(.p-card-body) {
z-index: 1;
flex-grow: 1;
justify-content: space-between;
}
.task-div {
> i {
pointer-events: none;
}
&:hover > i {
opacity: 0.2;
}
}
</style>

View File

@@ -1,10 +1,10 @@
<template>
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
<h1 class="font-inter font-semibold text-xl m-0 italic">
{{ $t(`desktopDialogs.${id}.title`, title) }}
{{ t(`desktopDialogs.${id}.title`, title) }}
</h1>
<p class="whitespace-pre-wrap">
{{ $t(`desktopDialogs.${id}.message`, message) }}
{{ t(`desktopDialogs.${id}.message`, message) }}
</p>
<div class="flex w-full gap-2">
<Button
@@ -12,7 +12,7 @@
:key="button.label"
class="rounded-lg first:mr-auto"
:label="
$t(
t(
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
button.label
)
@@ -31,6 +31,7 @@ import { useRoute } from 'vue-router'
import { getDialog } from '@/constants/desktopDialogs'
import type { DialogAction } from '@/constants/desktopDialogs'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
const route = useRoute()
@@ -40,3 +41,31 @@ const handleButtonClick = async (button: DialogAction) => {
await electronAPI().Dialog.clickButton(button.returnValue)
}
</script>
<style scoped>
@reference '../assets/css/style.css';
.p-button-secondary {
@apply text-white border-none bg-neutral-600;
}
.p-button-secondary:hover {
@apply bg-neutral-550;
}
.p-button-secondary:active {
@apply bg-neutral-500;
}
.p-button-danger {
@apply bg-coral-red-600;
}
.p-button-danger:hover {
@apply bg-coral-red-500;
}
.p-button-danger:active {
@apply bg-coral-red-400;
}
</style>

View File

@@ -6,11 +6,11 @@
<div class="relative m-8 text-center">
<!-- Header -->
<h1 class="download-bg pi-download text-4xl font-bold">
{{ $t('desktopUpdate.title') }}
{{ t('desktopUpdate.title') }}
</h1>
<div class="m-8">
<span>{{ $t('desktopUpdate.description') }}</span>
<span>{{ t('desktopUpdate.description') }}</span>
</div>
<ProgressSpinner class="m-8 w-48 h-48" />
@@ -19,7 +19,7 @@
<Button
style="transform: translateX(-50%)"
class="fixed bottom-0 left-1/2 my-8"
:label="$t('maintenance.consoleLogs')"
:label="t('maintenance.consoleLogs')"
icon="pi pi-desktop"
icon-pos="left"
severity="secondary"
@@ -28,8 +28,8 @@
<TerminalOutputDrawer
v-model="terminalVisible"
:header="$t('g.terminal')"
:default-message="$t('desktopUpdate.terminalDefaultMessage')"
:header="t('g.terminal')"
:default-message="t('desktopUpdate.terminalDefaultMessage')"
/>
</div>
</div>
@@ -44,6 +44,7 @@ import Toast from 'primevue/toast'
import { onUnmounted, ref } from 'vue'
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
@@ -60,10 +61,10 @@ onUnmounted(() => electron.Validation.dispose())
</script>
<style scoped>
@reference '../assets/css/style.css';
.download-bg::before {
position: absolute;
margin: 0;
color: var(--muted-foreground);
@apply m-0 absolute text-muted;
font-family: 'primeicons', sans-serif;
top: -2rem;
right: 2rem;

View File

@@ -183,37 +183,33 @@ onMounted(async () => {
</script>
<style scoped>
@reference '../assets/css/style.css';
:deep(.p-steppanel) {
margin-top: calc(var(--spacing) * 8);
display: flex;
justify-content: center;
background-color: transparent;
@apply mt-8 flex justify-center bg-transparent;
}
/* Remove default padding/margin from StepPanels to make scrollbar flush */
:deep(.p-steppanels) {
margin: 0;
padding: 0;
@apply p-0 m-0;
}
/* Ensure StepPanel content container has no top/bottom padding */
:deep(.p-steppanel-content) {
padding: 0;
@apply p-0;
}
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
:deep(.p-steppanels::-webkit-scrollbar) {
width: calc(var(--spacing) * 4);
@apply w-4;
}
:deep(.p-steppanels::-webkit-scrollbar-track) {
background-color: transparent;
@apply bg-transparent;
}
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
border: 4px solid transparent;
border-radius: var(--radius-lg);
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
background-clip: content-box;
}
</style>

View File

@@ -114,12 +114,12 @@ import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
@@ -129,7 +129,6 @@ import BaseViewTemplate from './templates/BaseViewTemplate.vue'
const electron = electronAPI()
const toast = useToast()
const { t } = useI18n()
const taskStore = useMaintenanceTaskStore()
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
@@ -221,14 +220,14 @@ onUnmounted(() => electron.Validation.dispose())
</script>
<style scoped>
@reference '../assets/css/style.css';
:deep(.p-tag) {
--p-tag-gap: 0.375rem;
}
.backspan::before {
position: absolute;
margin: 0;
color: var(--muted-foreground);
@apply m-0 absolute text-muted;
font-family: 'primeicons', sans-serif;
top: -2rem;
right: -2rem;

View File

@@ -1,6 +1,6 @@
<template>
<BaseViewTemplate>
<div class="sad-container grid items-center justify-evenly">
<div class="sad-container">
<!-- Right side image -->
<img
class="sad-girl"
@@ -79,7 +79,10 @@ const continueToInstall = async () => {
</script>
<style scoped>
@reference '../assets/css/style.css';
.sad-container {
@apply grid items-center justify-evenly;
grid-template-columns: 25rem 1fr;
& > * {

View File

@@ -232,6 +232,8 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../assets/css/style.css';
/* Hide the xterm scrollbar completely */
:deep(.p-terminal) .xterm-viewport {
overflow: hidden !important;

View File

@@ -44,12 +44,6 @@ export const TestIds = {
node: {
titleInput: 'node-title-input'
},
selectionToolbox: {
colorPickerButton: 'color-picker-button',
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red'
},
widgets: {
decrement: 'decrement',
increment: 'increment',
@@ -80,7 +74,6 @@ export type TestIdValue =
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<

View File

@@ -104,13 +104,15 @@ test.describe('Missing models warning', () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
})
test('Should display a warning when missing models are found in node properties', async ({
@@ -121,13 +123,15 @@ test.describe('Missing models warning', () => {
'missing/missing_models_from_node_properties'
)
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
})
test('Should not display a warning when no missing models are found', async ({
@@ -168,10 +172,8 @@ test.describe('Missing models warning', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
})
test('Should not display warning when model metadata exists but widget values have changed', async ({
@@ -184,10 +186,8 @@ test.describe('Missing models warning', () => {
)
// The missing models warning should NOT appear
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
})
// Flaky test after parallelization
@@ -199,15 +199,13 @@ test.describe('Missing models warning', () => {
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadButton = comfyPage.page.getByText('Download')
await expect(downloadButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadAllButton.click()
await downloadButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
@@ -231,13 +229,12 @@ test.describe('Missing models warning', () => {
test('Should disable warning dialog when checkbox is checked', async ({
comfyPage
}) => {
await checkbox.click()
const changeSettingPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
)
await checkbox.click()
await changeSettingPromise
await closeButton.click()
await changeSettingPromise
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -1,8 +1,6 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
const test = comfyPageFixture
@@ -12,17 +10,6 @@ test.beforeEach(async ({ comfyPage }) => {
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
const getColorPickerButton = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
const getColorPickerGroup = (comfyPage: { page: Page }) =>
comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
@@ -145,24 +132,28 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Color picker button should be visible
const colorPickerButton = getColorPickerButton(comfyPage)
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).toBeVisible()
// Click color picker button
await colorPickerButton.click()
// Color picker dropdown should be visible
const colorPickerGroup = getColorPickerGroup(comfyPage)
await expect(colorPickerGroup).toBeVisible()
const colorPickerDropdown = comfyPage.page.locator(
'.color-picker-container'
)
await expect(colorPickerDropdown).toBeVisible()
// Select a color (e.g., blue)
const blueColorOption = colorPickerGroup.getByTestId(
TestIds.selectionToolbox.colorBlue
const blueColorOption = colorPickerDropdown.locator(
'i[data-testid="blue"]'
)
await blueColorOption.click()
// Dropdown should close after selection
await expect(colorPickerGroup).not.toBeVisible()
await expect(colorPickerDropdown).not.toBeVisible()
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes
@@ -181,21 +172,22 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
'CLIP Text Encode (Prompt)'
])
const colorPickerButton = getColorPickerButton(comfyPage)
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
// Initially should show default color
await expect(colorPickerButton).not.toHaveAttribute('color')
// Click color picker and select a color
await colorPickerButton.click()
const redColorOption = getColorPickerGroup(comfyPage).getByTestId(
TestIds.selectionToolbox.colorRed
const redColorOption = comfyPage.page.locator(
'.color-picker-container i[data-testid="red"]'
)
await redColorOption.click()
// Button should now show the selected color
await expect(colorPickerCurrentColor).toHaveCSS('color', RED_COLOR)
await expect(colorPickerButton).toHaveCSS('color', RED_COLOR)
})
test('color picker shows mixed state for differently colored selections', async ({
@@ -203,17 +195,17 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// Select first node and color it
await comfyPage.nodeOps.selectNodes(['KSampler'])
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Select second node and color it differently
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorRed)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="red"]')
.click()
// Select both nodes
@@ -223,7 +215,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
])
// Color picker should show null/mixed state
const colorPickerButton = getColorPickerButton(comfyPage)
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).not.toHaveAttribute('color')
})
@@ -232,9 +226,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// First color a node
await comfyPage.nodeOps.selectNodes(['KSampler'])
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
.click()
// Clear selection
@@ -244,8 +238,10 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Color picker button should show the correct color
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
await expect(colorPickerCurrentColor).toHaveCSS('color', BLUE_COLOR)
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
})
test('colorization via color picker can be undone', async ({
@@ -253,9 +249,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// Select a node and color it
await comfyPage.nodeOps.selectNodes(['KSampler'])
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
.click()
// Undo the colorization

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import { TestIds } from '../../../fixtures/selectors'
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -20,16 +19,10 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
})
await loadCheckpointNode.getByText('Load Checkpoint').click()
const colorPickerButton = comfyPage.page.getByTestId(
TestIds.selectionToolbox.colorPickerButton
)
await colorPickerButton.click()
const colorPickerGroup = comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
await colorPickerGroup
.getByTestId(TestIds.selectionToolbox.colorBlue)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container')
.locator('i[data-testid="blue"]')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(

View File

@@ -39,10 +39,6 @@ Prefer Vue native options when available:
- Use inline Tailwind CSS only (no `<style>` blocks)
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
- Exception: when third-party libraries render runtime DOM outside Vue templates
(for example xterm internals inside PrimeVue terminal wrappers), scoped
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
exception is required.
## Best Practices

View File

@@ -4,11 +4,6 @@ export default {
'tests-ui/**': () =>
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
'./**/*.{css,vue}': (stagedFiles: string[]) => {
const joinedPaths = toJoinedRelativePaths(stagedFiles)
return [`pnpm exec stylelint --allow-empty-input ${joinedPaths}`]
},
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
@@ -27,17 +22,12 @@ export default {
}
function formatAndEslint(fileNames: string[]) {
const joinedPaths = toJoinedRelativePaths(fileNames)
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}
function toJoinedRelativePaths(fileNames: string[]) {
const relativePaths = fileNames.map((f) =>
path.relative(process.cwd(), f).replace(/\\/g, '/')
)
return relativePaths.map((p) => `"${p}"`).join(' ')
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.5",
"version": "1.46.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -29,10 +29,10 @@
"knip": "knip --cache",
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src --type-aware && eslint src",
"lint:no-cache": "oxlint src --type-aware && eslint src",
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "pnpm stylelint && oxlint src --type-aware && eslint src --cache",
"lint": "oxlint src --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",

View File

@@ -156,7 +156,6 @@
:root {
--fg-color: #000;
--bg-color: #fff;
--default-transition-duration: 0.1s;
--comfy-menu-bg: #353535;
--comfy-menu-secondary-bg: #292929;
--comfy-topbar-height: 2.5rem;

View File

@@ -3952,7 +3952,7 @@ export interface components {
* @description The subscription tier level
* @enum {string}
*/
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
/**
* @description The subscription billing duration
* @enum {string}

View File

@@ -1,14 +1,9 @@
import zipdir from 'zip-dir'
const sourceDir = process.argv[2] || './dist'
const outputPath = process.argv[3] || './dist.zip'
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
if (err) {
console.error(`Error zipping "${sourceDir}" directory:`, err)
console.error('Error zipping "dist" directory:', err)
} else {
process.stdout.write(
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
)
console.log('Successfully zipped "dist" directory.')
}
})

View File

@@ -2,28 +2,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadFile,
extractFilenameFromContentDisposition,
openFileInNewTab
extractFilenameFromContentDisposition
} from '@/base/common/downloadUtil'
const { mockIsCloud } = vi.hoisted(() => ({
mockIsCloud: { value: false }
}))
let mockIsCloud = false
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
return mockIsCloud
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
}))
// Global stubs
const createObjectURLSpy = vi
.spyOn(URL, 'createObjectURL')
@@ -37,7 +26,7 @@ describe('downloadUtil', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
mockIsCloud.value = false
mockIsCloud = false
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
@@ -165,7 +154,7 @@ describe('downloadUtil', () => {
})
it('streams downloads via blob when running in cloud', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -184,7 +173,6 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -195,7 +183,7 @@ describe('downloadUtil', () => {
})
it('logs an error when cloud fetch fails', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValue({
@@ -209,15 +197,14 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob throw
await Promise.resolve() // let .catch handler run
await Promise.resolve()
expect(consoleSpy).toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -236,7 +223,6 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -245,7 +231,7 @@ describe('downloadUtil', () => {
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -267,7 +253,6 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -275,7 +260,7 @@ describe('downloadUtil', () => {
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -293,7 +278,6 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -301,99 +285,6 @@ describe('downloadUtil', () => {
})
})
describe('openFileInNewTab', () => {
let windowOpenSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.useFakeTimers()
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
vi.useRealTimers()
})
it('opens URL directly when not in cloud mode', async () => {
mockIsCloud.value = false
const testUrl = 'https://example.com/image.png'
await openFileInNewTab(testUrl)
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
expect(fetchMock).not.toHaveBeenCalled()
})
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab(testUrl)
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
expect(fetchMock).toHaveBeenCalledWith(testUrl)
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
expect(mockTab.location.href).toBe('blob:mock-url')
})
it('revokes blob URL after timeout in cloud mode', async () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
vi.advanceTimersByTime(60_000)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
})
it('closes blank tab and logs error when cloud fetch fails', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: false,
status: 404
} as unknown as Response)
await openFileInNewTab(testUrl)
expect(mockTab.close).toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('revokes blob URL immediately if tab was closed by user', async () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
expect(mockTab.location.href).toBe('')
})
})
describe('extractFilenameFromContentDisposition', () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()

View File

@@ -1,9 +1,7 @@
/**
* Utility functions for downloading files
*/
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
// Constants
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
@@ -114,23 +112,14 @@ export function extractFilenameFromContentDisposition(
return null
}
/**
* Fetch a URL and return its body as a Blob.
* Shared by download and open-in-new-tab cloud paths.
*/
async function fetchAsBlob(url: string): Promise<Response> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`)
}
return response
}
async function downloadViaBlobFetch(
const downloadViaBlobFetch = async (
href: string,
fallbackFilename: string
): Promise<void> {
const response = await fetchAsBlob(href)
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
@@ -140,44 +129,3 @@ async function downloadViaBlobFetch(
const blob = await response.blob()
downloadBlob(headerFilename ?? fallbackFilename, blob)
}
/**
* Open a file URL in a new browser tab.
* On cloud, fetches the resource as a blob first to avoid GCS redirects
* that would trigger an auto-download instead of displaying the file.
*
* Opens the tab synchronously to preserve the user-gesture context
* (browsers block window.open after an await), then navigates it to
* the blob URL once the fetch completes.
*/
export async function openFileInNewTab(url: string): Promise<void> {
if (!isCloud) {
window.open(url, '_blank')
return
}
// Open immediately to preserve user-gesture activation.
const tab = window.open('', '_blank')
try {
const response = await fetchAsBlob(url)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
if (tab && !tab.closed) {
tab.location.href = blobUrl
// Revoke after the tab has had time to load the blob.
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
} else {
URL.revokeObjectURL(blobUrl)
}
} catch (error) {
tab?.close()
console.error('Failed to open image:', error)
useToastStore().addAlert(
t('toastMessages.errorOpenImage', {
error: error instanceof Error ? error.message : String(error)
})
)
}
}

View File

@@ -103,12 +103,13 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm {
overflow: hidden;
@apply overflow-hidden;
}
:deep(.p-terminal) .xterm-screen {
overflow: hidden;
background-color: var(--color-neutral-900);
@apply bg-neutral-900 overflow-hidden;
}
</style>

View File

@@ -195,6 +195,8 @@ onUpdated(() => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
.subgraph-breadcrumb:not(:empty) {
flex: auto;
flex-shrink: 10000;
@@ -203,7 +205,7 @@ onUpdated(() => {
.subgraph-breadcrumb,
:deep(.p-breadcrumb) {
overflow: hidden;
@apply overflow-hidden;
}
:deep(.p-breadcrumb) {
@@ -212,10 +214,7 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item) {
display: flex;
align-items: center;
overflow: hidden;
height: calc(var(--spacing) * 8);
@apply flex items-center overflow-hidden h-8;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
border: 1px solid transparent;
background-color: transparent;
@@ -237,7 +236,7 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:hover) {
border-radius: var(--radius-lg);
@apply rounded-lg;
border-color: var(--interface-stroke);
background-color: var(--comfy-menu-bg);
}
@@ -271,16 +270,18 @@ onUpdated(() => {
</style>
<style>
@reference '../../assets/css/style.css';
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
.p-breadcrumb-item,
.p-breadcrumb-separator {
display: none;
@apply hidden;
}
.p-breadcrumb-item:nth-last-child(3),
.p-breadcrumb-separator:nth-last-child(2),
.p-breadcrumb-item:nth-last-child(1) {
display: flex;
@apply flex;
}
}
</style>

View File

@@ -78,7 +78,9 @@ interface Props {
isActive?: boolean
}
const { item, isActive = false } = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
@@ -101,7 +103,7 @@ const rename = async (
) => {
if (newName && newName !== initialName) {
// Synchronize the node titles with the new name
item.updateTitle?.(newName)
props.item.updateTitle?.(newName)
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
@@ -125,13 +127,13 @@ const rename = async (
}
}
const isRoot = item.key === 'root'
const isRoot = props.item.key === 'root'
const tooltipText = computed(() => {
if (hasMissingNodes.value && isRoot) {
return t('breadcrumbsMenu.missingNodesWarning')
}
return item.label
return props.item.label
})
const startRename = async () => {
@@ -143,7 +145,7 @@ const startRename = async () => {
}
isEditing.value = true
itemLabel.value = item.label as string
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
@@ -163,12 +165,12 @@ const handleClick = (event: MouseEvent) => {
}
if (event.detail === 1) {
if (isActive) {
if (props.isActive) {
menu.value?.toggle(event)
} else {
item.command?.({ item: item, originalEvent: event })
props.item.command?.({ item: props.item, originalEvent: event })
}
} else if (isActive && event.detail === 2) {
} else if (props.isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
@@ -178,7 +180,7 @@ const handleClick = (event: MouseEvent) => {
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, item.label as string)
await rename(itemLabel.value, props.item.label as string)
}
isEditing.value = false
@@ -186,19 +188,19 @@ const inputBlur = async (doRename: boolean) => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
user-select: none;
@apply select-none;
}
.p-breadcrumb-item-link {
overflow: hidden;
@apply overflow-hidden;
}
.p-breadcrumb-item-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
.active-breadcrumb-item {

View File

@@ -0,0 +1,148 @@
<!-- A Electron-backed download button with a label, size hint and progress bar -->
<template>
<div class="flex flex-col">
<div class="flex flex-row items-center gap-2">
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
</div>
<div v-if="props.error" class="file-error">
{{ props.error }}
</div>
</div>
<div class="file-action flex flex-row items-center gap-2">
<Button
v-if="status === null || status === 'error'"
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
@click="triggerDownload"
>
<i class="pi pi-download" />
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
<Button
v-if="(status === null || status === 'error') && !!props.url"
variant="secondary"
size="sm"
@click="copyURL"
>
{{ $t('g.copyURL') }}
</Button>
</div>
</div>
<div
v-if="status === 'in_progress' || status === 'paused'"
class="flex flex-row items-center gap-2"
>
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
-->
<ProgressBar
class="flex-1"
:value="downloadProgress"
:show-value="downloadProgress > 10"
/>
<Button
v-if="status === 'in_progress'"
v-tooltip.top="t('electronFileDownload.pause')"
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
@click="triggerPauseDownload"
>
<i class="pi pi-pause-circle" />
</Button>
<Button
v-if="status === 'paused'"
v-tooltip.top="t('electronFileDownload.resume')"
class="file-action-button"
variant="secondary"
size="sm"
:aria-label="t('electronFileDownload.resume')"
:disabled="!!props.error"
@click="triggerResumeDownload"
>
<i class="pi pi-play-circle" />
</Button>
<Button
v-tooltip.top="t('electronFileDownload.cancel')"
class="file-action-button"
variant="destructive"
size="sm"
:aria-label="t('electronFileDownload.cancel')"
:disabled="!!props.error"
@click="triggerCancelDownload"
>
<i class="pi pi-times-circle" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const { t } = useI18n()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const downloadProgress = ref<number>(0)
const status = ref<string | null>(null)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const { copyToClipboard } = useCopyToClipboard()
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = props.label.split('/')
electronDownloadStore.$subscribe((_, { downloads }) => {
const download = downloads.find((download) => props.url === download.url)
if (download) {
// @ts-expect-error fixme ts strict error
downloadProgress.value = Number((download.progress * 100).toFixed(1))
// @ts-expect-error fixme ts strict error
status.value = download.status
}
})
const triggerDownload = async () => {
await electronDownloadStore.start({
url: props.url,
savePath: savePath.trim(),
filename: filename.trim()
})
}
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
const copyURL = async () => {
await copyToClipboard(props.url)
}
</script>

View File

@@ -0,0 +1,69 @@
<!-- A file download button with a label and a size hint -->
<template>
<div class="flex flex-row items-center gap-2">
<div>
<div>
<span :title="hint">{{ label }}</span>
</div>
<Message
v-if="props.error"
severity="error"
icon="pi pi-exclamation-triangle"
size="small"
variant="outlined"
class="my-2 h-min max-w-xs px-1"
:title="props.error"
:pt="{
text: { class: 'overflow-hidden text-ellipsis' }
}"
>
{{ props.error }}
</Message>
</div>
<div>
<Button
variant="secondary"
:disabled="!!props.error"
:title="props.url"
@click="download.triggerBrowserDownload"
>
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
</div>
<div>
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
{{ $t('g.copyURL') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -117,18 +117,20 @@ function getFormComponent(item: FormItem): Component {
</script>
<style scoped>
@reference '../../assets/css/style.css';
.form-input :deep(.input-slider) .p-inputnumber input,
.form-input :deep(.input-slider) .slider-part {
width: 5rem;
@apply w-20;
}
.form-input :deep(.input-knob) .p-inputnumber input,
.form-input :deep(.input-knob) .knob-part {
width: 8rem;
@apply w-32;
}
.form-input :deep(.p-inputtext),
.form-input :deep(.p-select) {
width: 11rem;
@apply w-44;
}
</style>

View File

@@ -133,6 +133,8 @@ const wrapperStyle = computed(() => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-inputtext) {
--p-form-field-padding-x: 0.625rem;
}

View File

@@ -1,6 +1,6 @@
<template>
<Chip removable @remove="emit('remove', $event)">
<Badge size="small" :class="semanticBadgeClass">
<Chip removable @remove="$emit('remove', $event)">
<Badge size="small" :class="badgeClass">
{{ badge }}
</Badge>
{{ text }}
@@ -10,7 +10,6 @@
<script setup lang="ts">
import Badge from 'primevue/badge'
import Chip from 'primevue/chip'
import { computed } from 'vue'
export interface SearchFilter {
text: string
@@ -19,19 +18,26 @@ export interface SearchFilter {
id: string | number
}
const semanticClassMap: Record<string, string> = {
'i-badge': 'bg-green-500 text-white',
'o-badge': 'bg-red-500 text-white',
'c-badge': 'bg-blue-500 text-white',
's-badge': 'bg-yellow-500'
defineProps<Omit<SearchFilter, 'id'>>()
defineEmits(['remove'])
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.i-badge) {
@apply bg-green-500 text-white;
}
const props = defineProps<Omit<SearchFilter, 'id'>>()
const emit = defineEmits<{
(e: 'remove', event: Event): void
}>()
:deep(.o-badge) {
@apply bg-red-500 text-white;
}
const semanticBadgeClass = computed(() => {
return semanticClassMap[props.badgeClass] ?? props.badgeClass
})
</script>
:deep(.c-badge) {
@apply bg-blue-500 text-white;
}
:deep(.s-badge) {
@apply bg-yellow-500;
}
</style>

View File

@@ -1,113 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import CurveEditor from './CurveEditor.vue'
function mountEditor(points: CurvePoint[], extraProps = {}) {
return mount(CurveEditor, {
props: { modelValue: points, ...extraProps }
})
}
function getCurvePath(wrapper: ReturnType<typeof mount>) {
return wrapper.find('[data-testid="curve-path"]')
}
describe('CurveEditor', () => {
it('renders SVG with curve path', () => {
const wrapper = mountEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('svg').exists()).toBe(true)
const curvePath = getCurvePath(wrapper)
expect(curvePath.exists()).toBe(true)
expect(curvePath.attributes('d')).toBeTruthy()
})
it('renders a circle for each control point', () => {
const wrapper = mountEditor([
[0, 0],
[0.5, 0.7],
[1, 1]
])
expect(wrapper.findAll('circle')).toHaveLength(3)
})
it('renders histogram path when provided', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const wrapper = mountEditor(
[
[0, 0],
[1, 1]
],
{ histogram }
)
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
expect(histogramPath.exists()).toBe(true)
expect(histogramPath.attributes('d')).toContain('M0,1')
})
it('does not render histogram path when not provided', () => {
const wrapper = mountEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
})
it('returns empty path with fewer than 2 points', () => {
const wrapper = mountEditor([[0.5, 0.5]])
expect(getCurvePath(wrapper).attributes('d')).toBe('')
})
it('generates path starting with M and containing L segments', () => {
const wrapper = mountEditor([
[0, 0],
[0.5, 0.8],
[1, 1]
])
const d = getCurvePath(wrapper).attributes('d')!
expect(d).toMatch(/^M/)
expect(d).toContain('L')
})
it('curve path only spans the x-range of control points', () => {
const wrapper = mountEditor([
[0.2, 0.3],
[0.8, 0.9]
])
const d = getCurvePath(wrapper).attributes('d')!
const xValues = d
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[0]))
expect(Math.min(...xValues)).toBeCloseTo(0.2, 2)
expect(Math.max(...xValues)).toBeCloseTo(0.8, 2)
})
it('deletes a point on right-click but keeps minimum 2', async () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.5],
[1, 1]
]
const wrapper = mountEditor(points)
expect(wrapper.findAll('circle')).toHaveLength(3)
await wrapper.findAll('circle')[1].trigger('pointerdown', {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
await wrapper.findAll('circle')[0].trigger('pointerdown', {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
})
})

View File

@@ -1,103 +0,0 @@
<template>
<svg
ref="svgRef"
viewBox="-0.04 -0.04 1.08 1.08"
preserveAspectRatio="xMidYMid meet"
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
@pointerdown.stop="handleSvgPointerDown"
@contextmenu.prevent.stop
>
<line
v-for="v in [0.25, 0.5, 0.75]"
:key="'h' + v"
:x1="0"
:y1="v"
:x2="1"
:y2="v"
stroke="currentColor"
stroke-opacity="0.1"
stroke-width="0.003"
/>
<line
v-for="v in [0.25, 0.5, 0.75]"
:key="'v' + v"
:x1="v"
:y1="0"
:x2="v"
:y2="1"
stroke="currentColor"
stroke-opacity="0.1"
stroke-width="0.003"
/>
<line
x1="0"
y1="1"
x2="1"
y2="0"
stroke="currentColor"
stroke-opacity="0.15"
stroke-width="0.003"
/>
<path
v-if="histogramPath"
data-testid="histogram-path"
:d="histogramPath"
:fill="curveColor"
fill-opacity="0.15"
stroke="none"
/>
<path
data-testid="curve-path"
:d="curvePath"
fill="none"
:stroke="curveColor"
stroke-width="0.008"
stroke-linecap="round"
/>
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
</svg>
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import { histogramToPath } from './curveUtils'
const { curveColor = 'white', histogram } = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
}>()
const modelValue = defineModel<CurvePoint[]>({
required: true
})
const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
svgRef,
modelValue
})
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)
</script>

View File

@@ -1,16 +0,0 @@
<template>
<CurveEditor v-model="modelValue" />
</template>
<script setup lang="ts">
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import CurveEditor from './CurveEditor.vue'
const modelValue = defineModel<CurvePoint[]>({
default: () => [
[0, 0],
[1, 1]
]
})
</script>

View File

@@ -1,141 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import {
createMonotoneInterpolator,
curvesToLUT,
histogramToPath
} from './curveUtils'
describe('createMonotoneInterpolator', () => {
it('returns 0 for empty points', () => {
const interpolate = createMonotoneInterpolator([])
expect(interpolate(0.5)).toBe(0)
})
it('returns constant for single point', () => {
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
expect(interpolate(0)).toBe(0.7)
expect(interpolate(1)).toBe(0.7)
})
it('passes through control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
it('clamps to endpoint values outside range', () => {
const points: CurvePoint[] = [
[0.2, 0.3],
[0.8, 0.9]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBe(0.3)
expect(interpolate(1)).toBe(0.9)
})
it('produces monotone output for monotone input', () => {
const points: CurvePoint[] = [
[0, 0],
[0.25, 0.2],
[0.5, 0.5],
[0.75, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
let prev = -Infinity
for (let x = 0; x <= 1; x += 0.01) {
const y = interpolate(x)
expect(y).toBeGreaterThanOrEqual(prev)
prev = y
}
})
it('handles unsorted input points', () => {
const points: CurvePoint[] = [
[1, 1],
[0, 0],
[0.5, 0.5]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
})
describe('curvesToLUT', () => {
it('returns a 256-entry Uint8Array', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
expect(lut).toBeInstanceOf(Uint8Array)
expect(lut.length).toBe(256)
})
it('produces identity LUT for diagonal curve', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeCloseTo(i, 0)
}
})
it('clamps output to [0, 255]', () => {
const lut = curvesToLUT([
[0, 0],
[0.5, 1.5],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeGreaterThanOrEqual(0)
expect(lut[i]).toBeLessThanOrEqual(255)
}
})
})
describe('histogramToPath', () => {
it('returns empty string for empty histogram', () => {
expect(histogramToPath(new Uint32Array(0))).toBe('')
})
it('returns empty string when all bins are zero', () => {
expect(histogramToPath(new Uint32Array(256))).toBe('')
})
it('returns a closed SVG path for valid histogram', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const path = histogramToPath(histogram)
expect(path).toMatch(/^M0,1/)
expect(path).toMatch(/L1,1 Z$/)
})
it('normalizes using 99.5th percentile to suppress outliers', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = 100
histogram[255] = 100000
const path = histogramToPath(histogram)
// Most bins should map to y=0 (1 - 100/100 = 0) since
// the 99.5th percentile is 100, not the outlier 100000
const yValues = path
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[1]))
.filter((y) => !isNaN(y))
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
expect(nearZero.length).toBeGreaterThan(200)
})
})

View File

@@ -1,120 +0,0 @@
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points
* without overshooting (monotone property).
*
* Returns a function that evaluates y for any x in [0, 1].
*/
export function createMonotoneInterpolator(
points: CurvePoint[]
): (x: number) => number {
if (points.length === 0) return () => 0
if (points.length === 1) return () => points[0][1]
const sorted = [...points].sort((a, b) => a[0] - b[0])
const n = sorted.length
const xs = sorted.map((p) => p[0])
const ys = sorted.map((p) => p[1])
const deltas: number[] = []
const slopes: number[] = []
for (let i = 0; i < n - 1; i++) {
const dx = xs[i + 1] - xs[i]
deltas.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx)
}
slopes.push(deltas[0] ?? 0)
for (let i = 1; i < n - 1; i++) {
if (deltas[i - 1] * deltas[i] <= 0) {
slopes.push(0)
} else {
slopes.push((deltas[i - 1] + deltas[i]) / 2)
}
}
slopes.push(deltas[n - 2] ?? 0)
for (let i = 0; i < n - 1; i++) {
if (deltas[i] === 0) {
slopes[i] = 0
slopes[i + 1] = 0
} else {
const alpha = slopes[i] / deltas[i]
const beta = slopes[i + 1] / deltas[i]
const s = alpha * alpha + beta * beta
if (s > 9) {
const t = 3 / Math.sqrt(s)
slopes[i] = t * alpha * deltas[i]
slopes[i + 1] = t * beta * deltas[i]
}
}
}
return (x: number): number => {
if (x <= xs[0]) return ys[0]
if (x >= xs[n - 1]) return ys[n - 1]
let lo = 0
let hi = n - 1
while (lo < hi - 1) {
const mid = (lo + hi) >> 1
if (xs[mid] <= x) lo = mid
else hi = mid
}
const dx = xs[hi] - xs[lo]
if (dx === 0) return ys[lo]
const t = (x - xs[lo]) / dx
const t2 = t * t
const t3 = t2 * t
const h00 = 2 * t3 - 3 * t2 + 1
const h10 = t3 - 2 * t2 + t
const h01 = -2 * t3 + 3 * t2
const h11 = t3 - t2
return (
h00 * ys[lo] +
h10 * dx * slopes[lo] +
h01 * ys[hi] +
h11 * dx * slopes[hi]
)
}
}
/**
* Convert a 256-bin histogram into an SVG path string.
* Normalizes using the 99.5th percentile to avoid outlier spikes.
*/
export function histogramToPath(histogram: Uint32Array): string {
if (!histogram.length) return ''
const sorted = Array.from(histogram).sort((a, b) => a - b)
const max = sorted[Math.floor(255 * 0.995)]
if (max === 0) return ''
const step = 1 / 255
let d = 'M0,1'
for (let i = 0; i < 256; i++) {
const x = i * step
const y = 1 - Math.min(1, histogram[i] / max)
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
}
d += ' L1,1 Z'
return d
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
const lut = new Uint8Array(256)
const interpolate = createMonotoneInterpolator(points)
for (let i = 0; i < 256; i++) {
const x = i / 255
const y = interpolate(x)
lut[i] = Math.max(0, Math.min(255, Math.round(y * 255)))
}
return lut
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,30 +71,20 @@ function getDialogPt(item: {
</script>
<style>
@reference '../../assets/css/style.css';
.global-dialog {
max-width: calc(100vw - 1rem);
}
.global-dialog .p-dialog-header {
padding: calc(var(--spacing) * 2);
padding-bottom: 0;
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
@apply pb-0;
}
.global-dialog .p-dialog-content {
padding: calc(var(--spacing) * 2);
padding-top: 0;
}
@media (min-width: 1536px) {
.global-dialog .p-dialog-header {
padding: var(--p-dialog-header-padding);
padding-bottom: 0;
}
.global-dialog .p-dialog-content {
padding: var(--p-dialog-content-padding);
padding-top: 0;
}
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
/* Workspace mode: wider settings dialog */

View File

@@ -1,173 +0,0 @@
<template>
<div
class="flex w-full max-w-[490px] flex-col border-t border-border-default"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<p class="m-0 text-sm leading-5 text-muted-foreground">
{{ $t('missingModelsDialog.description') }}
</p>
<div
class="flex max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="model in processedModels"
:key="model.name"
class="flex items-center justify-between px-3 py-2"
>
<div class="flex items-center gap-2 overflow-hidden">
<span
class="min-w-0 truncate text-sm text-foreground"
:title="model.name"
>
{{ model.name }}
</span>
<span
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold uppercase text-muted-foreground"
>
{{ model.badgeLabel }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
v-if="model.isDownloadable && fileSizes.get(model.url)"
class="text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<Button
v-if="model.isDownloadable"
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<Button
v-else
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.copyURL')"
@click="void copyToClipboard(model.url)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
</div>
<div
v-if="totalDownloadSize > 0"
class="sticky bottom-0 flex items-center justify-between border-t border-border-default bg-secondary-background px-3 py-2"
>
<span class="text-xs font-medium text-muted-foreground">
{{ $t('missingModelsDialog.totalSize') }}
</span>
<span class="text-xs text-muted-foreground">
{{ formatSize(totalDownloadSize) }}
</span>
</div>
</div>
<p
class="m-0 text-xs leading-5 text-muted-foreground whitespace-pre-line"
>
{{ $t('missingModelsDialog.footerDescription') }}
</p>
<div
v-if="hasCustomModels"
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<div class="flex flex-col gap-1">
<p
class="m-0 text-xs font-semibold leading-5 text-warning-background"
>
{{ $t('missingModelsDialog.customModelsWarning') }}
</p>
<p class="m-0 text-xs leading-5 text-warning-background">
{{ $t('missingModelsDialog.customModelsInstruction') }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { formatSize } from '@/utils/formatUtil'
import type { ModelWithUrl } from './missingModelsUtils'
import {
downloadModel,
getBadgeLabel,
hasValidDirectory,
isModelDownloadable
} from './missingModelsUtils'
const { missingModels, paths } = defineProps<{
missingModels: ModelWithUrl[]
paths: Record<string, string[]>
}>()
interface ProcessedModel {
name: string
url: string
directory: string
badgeLabel: string
isDownloadable: boolean
}
const processedModels = computed<ProcessedModel[]>(() =>
missingModels.map((model) => ({
name: model.name,
url: model.url,
directory: model.directory,
badgeLabel: getBadgeLabel(model.directory),
isDownloadable:
hasValidDirectory(model, paths) && isModelDownloadable(model)
}))
)
const hasCustomModels = computed(() =>
processedModels.value.some((m) => !m.isDownloadable)
)
const fileSizes = reactive(new Map<string, number>())
const totalDownloadSize = computed(() =>
processedModels.value
.filter((model) => model.isDownloadable)
.reduce((total, model) => total + (fileSizes.get(model.url) ?? 0), 0)
)
onMounted(async () => {
const downloadableUrls = processedModels.value
.filter((m) => m.isDownloadable)
.map((m) => m.url)
await Promise.allSettled(
downloadableUrls.map(async (url) => {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) return
const size = response.headers.get('content-length')
if (size) fileSizes.set(url, parseInt(size, 10))
} catch {
// Silently skip size fetch failures
}
})
)
})
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -1,103 +0,0 @@
<template>
<div class="flex w-full flex-col gap-2 px-4 py-2">
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
<div class="flex items-center gap-1">
<input
id="doNotAskAgainModels"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgainModels">{{
$t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="ml-6 text-sm text-muted-foreground"
>
<template #link>
<Button
variant="textonly"
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<div class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleAction">
{{ buttonLabel }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import type { ModelWithUrl } from './missingModelsUtils'
import {
downloadModel,
hasValidDirectory,
isModelDownloadable
} from './missingModelsUtils'
const { missingModels, paths } = defineProps<{
missingModels: ModelWithUrl[]
paths: Record<string, string[]>
}>()
const DIALOG_KEY = 'global-missing-models-warning'
const { t } = useI18n()
const dialogStore = useDialogStore()
const doNotAskAgain = ref(false)
watch(doNotAskAgain, (value) => {
void useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', !value)
})
function openShowMissingModelsSetting() {
dialogStore.closeDialog({ key: DIALOG_KEY })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const downloadableModels = computed(() =>
missingModels.filter(
(model) => hasValidDirectory(model, paths) && isModelDownloadable(model)
)
)
const hasDownloadable = computed(() => downloadableModels.value.length > 0)
const hasCustom = computed(
() => downloadableModels.value.length < missingModels.length
)
const buttonLabel = computed(() => {
if (hasDownloadable.value && hasCustom.value)
return t('missingModelsDialog.downloadAvailable')
if (hasDownloadable.value) return t('missingModelsDialog.downloadAll')
return t('missingModelsDialog.gotIt')
})
function handleAction() {
if (hasDownloadable.value) {
for (const model of downloadableModels.value) {
downloadModel(model, paths)
}
}
dialogStore.closeDialog({ key: DIALOG_KEY })
}
</script>

View File

@@ -1,10 +0,0 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
<p class="m-0 text-sm">
{{ $t('missingModelsDialog.title') }}
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,177 @@
<template>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
/>
<div class="mb-4 flex flex-col gap-1">
<div class="flex gap-1">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
<Suspense v-if="isDesktop">
<ElectronFileDownload
:url="option.url"
:label="option.label"
:error="option.error"
/>
</Suspense>
<FileDownload
v-else
:url="option.url"
:label="option.label"
:error="option.error"
/>
</template>
</ListBox>
</template>
<script setup lang="ts">
import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
const allowedSources = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:' // Included for testing usage only
]
const allowedSuffixes = ['.safetensors', '.sft']
// Models that fail above conditions but are still allowed
const whiteListedUrls = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
interface ModelInfo {
name: string
directory: string
url: string
downloading?: boolean
completed?: boolean
progress?: number
error?: string
folder_path?: string
}
const props = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
}>()
const { t } = useI18n()
const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
const paths = props.paths[model.directory]
if (!paths) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: 'Invalid directory specified (does this require custom nodes?)'
}
}
const downloadInfo: ModelInfo = modelDownloads.value[model.name] ?? {
downloading: false,
completed: false,
progress: 0,
error: null,
name: model.name,
directory: model.directory,
url: model.url,
folder_path: paths[0]
}
modelDownloads.value[model.name] = downloadInfo
if (!whiteListedUrls.has(model.url)) {
if (!allowedSources.some((source) => model.url.startsWith(source))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
}
}
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
}
}
}
return {
url: model.url,
label: `${model.directory} / ${model.name}`,
downloading: downloadInfo.downloading,
completed: downloadInfo.completed,
progress: downloadInfo.progress,
error: downloadInfo.error,
name: model.name,
paths: paths,
folderPath: downloadInfo.folder_path
}
})
})
onBeforeUnmount(async () => {
if (doNotAskAgain.value) {
await useSettingStore().set(
'Comfy.Workflow.ShowMissingModelsWarning',
false
)
}
})
</script>
<style scoped>
.comfy-missing-models {
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -1,83 +0,0 @@
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
const ALLOWED_SOURCES = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:'
] as const
const ALLOWED_SUFFIXES = [
'.safetensors',
'.sft',
'.ckpt',
'.pth',
'.pt'
] as const
const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
const DIRECTORY_BADGE_MAP = {
vae: 'VAE',
diffusion_models: 'DIFFUSION',
text_encoders: 'TEXT ENCODER',
loras: 'LORA',
checkpoints: 'CHECKPOINT'
} as const
export interface ModelWithUrl {
name: string
url: string
directory: string
}
export function isModelDownloadable(model: ModelWithUrl): boolean {
if (WHITE_LISTED_URLS.has(model.url)) return true
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))
return false
if (!ALLOWED_SUFFIXES.some((suffix) => model.name.endsWith(suffix)))
return false
return true
}
export function hasValidDirectory(
model: ModelWithUrl,
paths: Record<string, string[]>
): boolean {
return !!paths[model.directory]
}
export function getBadgeLabel(directory: string): string {
if (directory in DIRECTORY_BADGE_MAP) {
return DIRECTORY_BADGE_MAP[directory as keyof typeof DIRECTORY_BADGE_MAP]
}
return directory.toUpperCase()
}
export function downloadModel(
model: ModelWithUrl,
paths: Record<string, string[]>
): void {
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url
link.download = model.name
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.click()
return
}
const modelPaths = paths[model.directory]
if (modelPaths?.[0]) {
void useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],
filename: model.name
})
}
}

View File

@@ -17,9 +17,9 @@
}"
@row-dblclick="editKeybinding($event.data)"
>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<Column field="actions" header="">
<template #body="slotProps">
<div class="actions flex flex-row">
<div class="actions invisible flex flex-row">
<Button
variant="textonly"
size="icon"
@@ -56,7 +56,6 @@
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div class="truncate" :title="slotProps.data.id">
@@ -64,11 +63,7 @@
</div>
</template>
</Column>
<Column
field="keybinding"
:header="$t('g.keybinding')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<Column field="keybinding" :header="$t('g.keybinding')">
<template #body="slotProps">
<KeyComboDisplay
v-if="slotProps.data.keybinding"
@@ -80,11 +75,7 @@
<span v-else>-</span>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<Column field="source" :header="$t('g.source')">
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
slotProps.data.source || '-'
@@ -302,3 +293,17 @@ async function resetAllKeybindings() {
})
}
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-datatable-tbody) > tr > td {
@apply p-1;
min-height: 2rem;
}
:deep(.p-datatable-row-selected) .actions,
:deep(.p-datatable-selectable-row:hover) .actions {
@apply visible;
}
</style>

View File

@@ -98,17 +98,16 @@ describe('SignInForm', () => {
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
'span.text-muted.text-base.font-medium.cursor-pointer'
)
expect(forgotPasswordSpan.classes()).toContain('cursor-not-allowed')
expect(forgotPasswordSpan.classes()).toContain('opacity-50')
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock getElementById to track focus
@@ -153,7 +152,7 @@ describe('SignInForm', () => {
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Click the forgot password link

View File

@@ -34,13 +34,10 @@
{{ t('auth.login.passwordLabel') }}
</label>
<span
:class="
cn('text-base font-medium text-muted select-none', {
'cursor-not-allowed opacity-50':
!$form.email?.value || $form.email?.invalid,
'cursor-pointer': $form.email?.value && !$form.email?.invalid
})
"
class="cursor-pointer text-base font-medium text-muted select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
>
{{ t('auth.login.forgotPassword') }}
@@ -92,7 +89,6 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthAction
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { cn } from '@/utils/tailwindUtil'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
@@ -130,3 +126,11 @@ const handleForgotPassword = async (
await firebaseAuthActions.sendPasswordReset(email)
}
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
.text-link-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

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

View File

@@ -8,38 +8,20 @@ import { createI18n } from 'vue-i18n'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraph } from '@/scripts/defaultGraph'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
function createMockWorkflow(
overrides: Partial<LoadedComfyWorkflow> = {}
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflow({
path: 'workflows/color-picker-test.json',
modified: 0,
size: 0
})
const changeTracker = Object.assign(
new ChangeTracker(workflow, structuredClone(defaultGraph)),
{
return {
changeTracker: {
checkState: vi.fn() as Mock
}
)
const workflowOverrides = {
changeTracker,
},
...overrides
} satisfies Partial<LoadedComfyWorkflow>
return Object.assign(workflow, workflowOverrides) as LoadedComfyWorkflow
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
}
// Mock the litegraph module
@@ -128,14 +110,12 @@ describe('ColorPickerButton', () => {
const wrapper = createWrapper()
const button = wrapper.find('button')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
await button.trigger('click')
const picker = wrapper.findComponent({ name: 'SelectButton' })
expect(picker.exists()).toBe(true)
expect(picker.findAll('button').length).toBeGreaterThan(0)
expect(wrapper.find('.color-picker-container').exists()).toBe(true)
await button.trigger('click')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
})
})

View File

@@ -11,17 +11,13 @@
@click="() => (showColorPicker = !showColorPicker)"
>
<div class="flex items-center gap-1 px-0">
<i
class="pi pi-circle-fill"
data-testid="color-picker-current-color"
:style="{ color: currentColor ?? '' }"
/>
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
<i class="icon-[lucide--chevron-down]" />
</div>
</Button>
<div
v-if="showColorPicker"
class="absolute -top-10 left-1/2 -translate-x-1/2"
class="color-picker-container absolute -top-10 left-1/2"
>
<SelectButton
:model-value="selectedColorOption"
@@ -163,7 +159,13 @@ watch(
</script>
<style scoped>
@reference '../../../assets/css/style.css';
.color-picker-container {
transform: translateX(-50%);
}
:deep(.p-togglebutton) {
padding: calc(var(--spacing) * 2) var(--spacing);
@apply py-2 px-1;
}
</style>

View File

@@ -2,14 +2,13 @@
<div
v-show="widgetState.visible"
ref="widgetElement"
class="dom-widget h-full w-full"
class="dom-widget"
:title="tooltip"
:style="style"
>
<component
:is="widget.component"
v-if="isComponentWidget(widget)"
class="h-full w-full"
:model-value="widget.value"
:widget="widget"
v-bind="widget.props"
@@ -175,8 +174,6 @@ const mountElementIfVisible = () => {
if (widgetElement.value.contains(widget.element)) {
return
}
widget.element.classList.add('h-full', 'w-full')
widgetElement.value.appendChild(widget.element)
}
@@ -199,3 +196,11 @@ watch(
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
</script>
<style scoped>
@reference '../../../assets/css/style.css';
.dom-widget > * {
@apply h-full w-full;
}
</style>

View File

@@ -24,7 +24,9 @@ interface Props {
modelValue: number
}
const { label, min, max, step = 1, modelValue } = defineProps<Props>()
withDefaults(defineProps<Props>(), {
step: 1
})
const emit = defineEmits<{
'update:modelValue': [value: number]

View File

@@ -8,14 +8,11 @@
<!-- Markdown fetched successfully -->
<div
v-else-if="!error"
class="markdown-content overflow-visible text-sm leading-(--text-sm--line-height)"
class="markdown-content"
v-html="renderedHelpHtml"
/>
<!-- Fallback: markdown not found or fetch error -->
<div
v-else
class="fallback-content space-y-6 text-sm leading-(--text-sm--line-height)"
>
<div v-else class="fallback-content space-y-6 text-sm">
<p v-if="node.description">
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
</p>
@@ -25,52 +22,48 @@
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
</p>
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="input in inputList" :key="input.name">
<td>
<code>{{ input.name }}</code>
</td>
<td>{{ input.type }}</td>
<td>{{ input.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<table class="overflow-x-auto">
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="input in inputList" :key="input.name">
<td>
<code>{{ input.name }}</code>
</td>
<td>{{ input.type }}</td>
<td>{{ input.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="outputList.length">
<p>
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
</p>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="output in outputList" :key="output.name">
<td>
<code>{{ output.name }}</code>
</td>
<td>{{ output.type }}</td>
<td>{{ output.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<table class="overflow-x-auto">
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="output in outputList" :key="output.name">
<td>
<code>{{ output.name }}</code>
</td>
<td>{{ output.type }}</td>
<td>{{ output.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -107,59 +100,39 @@ const outputList = computed(() =>
</script>
<style scoped>
@reference './../../assets/css/style.css';
.node-help-content :deep(:is(img, video)) {
display: block;
max-width: 100%;
height: auto;
margin-bottom: calc(var(--spacing) * 4);
@apply max-w-full h-auto block mb-4;
}
.markdown-content,
.fallback-content {
@apply text-sm overflow-visible;
}
.markdown-content :deep(h1),
.fallback-content h1 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h2),
.fallback-content h2 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-lg);
font-weight: var(--font-weight-bold);
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h3),
.fallback-content h3 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-base);
font-weight: var(--font-weight-bold);
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h4),
.fallback-content h4 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-sm);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(h5),
.fallback-content h5 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-sm);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(h6),
.fallback-content h4,
.fallback-content h5,
.fallback-content h6 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-xs);
font-weight: var(--font-weight-bold);
@apply mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(td),
@@ -182,8 +155,7 @@ const outputList = computed(() =>
.markdown-content :deep(ol),
.fallback-content ul,
.fallback-content ol {
margin-block: calc(var(--spacing) * 2);
padding-left: calc(var(--spacing) * 8);
@apply pl-8 my-2;
}
.markdown-content :deep(ul ul),
@@ -194,42 +166,36 @@ const outputList = computed(() =>
.fallback-content ol ol,
.fallback-content ul ol,
.fallback-content ol ul {
margin-block: calc(var(--spacing) * 2);
padding-left: calc(var(--spacing) * 6);
@apply pl-6 my-2;
}
.markdown-content :deep(li),
.fallback-content li {
margin-block: calc(var(--spacing) * 2);
@apply my-2;
}
.markdown-content :deep(*:first-child),
.fallback-content > *:first-child {
margin-top: 0;
@apply mt-0;
}
.markdown-content :deep(code),
.fallback-content code {
color: var(--code-text-color);
background-color: var(--code-bg-color);
border-radius: var(--radius);
padding: calc(var(--spacing) * 0.5) calc(var(--spacing) * 1.5);
@apply rounded px-1.5 py-0.5;
}
.markdown-content :deep(table),
.fallback-content table {
border-collapse: collapse;
}
.fallback-content table {
width: 100%;
@apply w-full border-collapse;
}
.markdown-content :deep(th),
.markdown-content :deep(td),
.fallback-content th,
.fallback-content td {
padding: calc(var(--spacing) * 2);
@apply px-2 py-2;
}
.markdown-content :deep(tr),
@@ -249,22 +215,16 @@ const outputList = computed(() =>
.markdown-content :deep(pre),
.fallback-content pre {
margin-block: calc(var(--spacing) * 4);
overflow-x: auto;
border-radius: var(--radius);
padding: calc(var(--spacing) * 4);
@apply rounded p-4 my-4 overflow-x-auto;
background-color: var(--code-block-bg-color);
code {
background-color: transparent;
padding: 0;
@apply bg-transparent p-0;
color: var(--p-text-color);
}
}
.markdown-content :deep(table) {
display: block;
width: 100%;
overflow-x: auto;
@apply overflow-x-auto;
}
</style>

View File

@@ -75,10 +75,15 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
type OverlayState = 'hidden' | 'active' | 'expanded'
const { expanded, menuHovered = false } = defineProps<{
expanded?: boolean
menuHovered?: boolean
}>()
const props = withDefaults(
defineProps<{
expanded?: boolean
menuHovered?: boolean
}>(),
{
menuHovered: false
}
)
const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
@@ -101,12 +106,13 @@ const {
currentNodeProgressStyle
} = useQueueProgress()
const isHovered = ref(false)
const isOverlayHovered = computed(() => isHovered.value || menuHovered)
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
const internalExpanded = ref(false)
const isExpanded = computed({
get: () => (expanded === undefined ? internalExpanded.value : expanded),
get: () =>
props.expanded === undefined ? internalExpanded.value : props.expanded,
set: (value) => {
if (expanded === undefined) {
if (props.expanded === undefined) {
internalExpanded.value = value
}
emit('update:expanded', value)

View File

@@ -17,7 +17,10 @@
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<JobDetailsPopover :job-id="jobId" :workflow-id="workflowId" />
<JobDetailsPopover
:job-id="props.jobId"
:workflow-id="props.workflowId"
/>
</div>
</Teleport>
<Teleport to="body">
@@ -33,7 +36,7 @@
>
<QueueAssetPreview
:image-url="iconImageUrl!"
:name="title"
:name="props.title"
:time-label="rightText || undefined"
@image-click="emit('view')"
/>
@@ -46,20 +49,23 @@
>
<div
v-if="
state === 'running' &&
hasAnyProgressPercent(progressTotalPercent, progressCurrentPercent)
props.state === 'running' &&
hasAnyProgressPercent(
props.progressTotalPercent,
props.progressCurrentPercent
)
"
:class="progressBarContainerClass"
>
<div
v-if="hasProgressPercent(progressTotalPercent)"
v-if="hasProgressPercent(props.progressTotalPercent)"
:class="progressBarPrimaryClass"
:style="progressPercentStyle(progressTotalPercent)"
:style="progressPercentStyle(props.progressTotalPercent)"
/>
<div
v-if="hasProgressPercent(progressCurrentPercent)"
v-if="hasProgressPercent(props.progressCurrentPercent)"
:class="progressBarSecondaryClass"
:style="progressPercentStyle(progressCurrentPercent)"
:style="progressPercentStyle(props.progressCurrentPercent)"
/>
</div>
@@ -87,8 +93,8 @@
</div>
<div class="relative z-1 min-w-0 flex-1">
<div class="truncate opacity-90" :title="title">
<slot name="primary">{{ title }}</slot>
<div class="truncate opacity-90" :title="props.title">
<slot name="primary">{{ props.title }}</slot>
</div>
</div>
@@ -125,7 +131,7 @@
class="inline-flex items-center gap-2 pr-1"
>
<Button
v-if="state === 'failed' && computedShowClear"
v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
variant="destructive"
size="icon"
@@ -136,8 +142,8 @@
</Button>
<Button
v-else-if="
state !== 'completed' &&
state !== 'running' &&
props.state !== 'completed' &&
props.state !== 'running' &&
computedShowClear
"
v-tooltip.top="cancelTooltipConfig"
@@ -149,14 +155,14 @@
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="state === 'completed'"
v-else-if="props.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emit('view')"
>{{ t('menuLabels.View') }}</Button
>
<Button
v-if="showMenu !== undefined ? showMenu : true"
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon-sm"
@@ -166,13 +172,17 @@
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
</div>
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
<slot name="secondary">{{ rightText }}</slot>
<div
v-else-if="props.state !== 'running'"
key="secondary"
class="pr-2"
>
<slot name="secondary">{{ props.rightText }}</slot>
</div>
</Transition>
<!-- Running job cancel button - always visible -->
<Button
v-if="state === 'running' && computedShowClear"
v-if="props.state === 'running' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
@@ -199,33 +209,34 @@ import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const {
jobId,
workflowId,
state,
title,
rightText = '',
iconName,
iconImageUrl,
showClear,
showMenu,
progressTotalPercent,
progressCurrentPercent,
activeDetailsId = null
} = defineProps<{
jobId: string
workflowId?: string
state: JobState
title: string
rightText?: string
iconName?: string
iconImageUrl?: string
showClear?: boolean
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
activeDetailsId?: string | null
}>()
const props = withDefaults(
defineProps<{
jobId: string
workflowId?: string
state: JobState
title: string
rightText?: string
iconName?: string
iconImageUrl?: string
showClear?: boolean
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
activeDetailsId?: string | null
}>(),
{
workflowId: undefined,
rightText: '',
iconName: undefined,
iconImageUrl: undefined,
showClear: undefined,
showMenu: undefined,
progressTotalPercent: undefined,
progressCurrentPercent: undefined,
runningNodeName: undefined,
activeDetailsId: null
}
)
const emit = defineEmits<{
(e: 'cancel'): void
@@ -251,14 +262,14 @@ const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => activeDetailsId === jobId)
const showDetails = computed(() => props.activeDetailsId === props.jobId)
const onRowEnter = () => {
if (!isPreviewVisible.value) emit('details-enter', jobId)
if (!isPreviewVisible.value) emit('details-enter', props.jobId)
}
const onRowLeave = () => emit('details-leave', jobId)
const onPopoverEnter = () => emit('details-enter', jobId)
const onPopoverLeave = () => emit('details-leave', jobId)
const onRowLeave = () => emit('details-leave', props.jobId)
const onPopoverEnter = () => emit('details-enter', props.jobId)
const onPopoverLeave = () => emit('details-leave', props.jobId)
const isPreviewVisible = ref(false)
const previewHideTimer = ref<number | null>(null)
@@ -275,7 +286,9 @@ const clearPreviewShowTimer = () => {
previewShowTimer.value = null
}
}
const canShowPreview = computed(() => state === 'completed' && !!iconImageUrl)
const canShowPreview = computed(
() => props.state === 'completed' && !!props.iconImageUrl
)
const scheduleShowPreview = () => {
if (!canShowPreview.value) return
clearPreviewHideTimer()
@@ -330,23 +343,23 @@ watch(
const isHovered = ref(false)
const iconClass = computed(() => {
if (iconName) return iconName
return iconForJobState(state)
if (props.iconName) return props.iconName
return iconForJobState(props.state)
})
const shouldSpin = computed(
() =>
state === 'pending' &&
props.state === 'pending' &&
iconClass.value === iconForJobState('pending') &&
!iconImageUrl
!props.iconImageUrl
)
const computedShowClear = computed(() => {
if (showClear !== undefined) return showClear
return state !== 'completed'
if (props.showClear !== undefined) return props.showClear
return props.state !== 'completed'
})
const emitDetailsLeave = () => emit('details-leave', jobId)
const emitDetailsLeave = () => emit('details-leave', props.jobId)
const onCancelClick = () => {
emitDetailsLeave()
@@ -359,7 +372,7 @@ const onDeleteClick = () => {
}
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = showMenu !== undefined ? showMenu : true
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
if (shouldShowMenu) emit('menu', event)
}
</script>

View File

@@ -11,22 +11,21 @@ interface Props {
disable?: boolean
}
const {
duration = 150,
easingEnter = 'ease-in-out',
easingLeave = 'ease-in-out',
opacityClosed = 0,
opacityOpened = 1,
disable
} = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
duration: 150,
easingEnter: 'ease-in-out',
easingLeave: 'ease-in-out',
opacityClosed: 0,
opacityOpened: 1
})
const closed = '0px'
const isMounted = ref(false)
onMounted(() => (isMounted.value = true))
const animationDuration = computed(() =>
isMounted.value && !disable ? duration : 0
const duration = computed(() =>
isMounted.value && !props.disable ? props.duration : 0
)
interface initialStyle {
@@ -96,7 +95,7 @@ function getEnterKeyframes(height: string, initialStyle: initialStyle) {
return [
{
height: closed,
opacity: opacityClosed,
opacity: props.opacityClosed,
paddingTop: closed,
paddingBottom: closed,
borderTopWidth: closed,
@@ -106,7 +105,7 @@ function getEnterKeyframes(height: string, initialStyle: initialStyle) {
},
{
height,
opacity: opacityOpened,
opacity: props.opacityOpened,
paddingTop: initialStyle.paddingTop,
paddingBottom: initialStyle.paddingBottom,
borderTopWidth: initialStyle.borderTopWidth,
@@ -122,7 +121,7 @@ function enterTransition(element: Element, done: () => void) {
const initialStyle = getElementStyle(HTMLElement)
const height = prepareElement(HTMLElement, initialStyle)
const keyframes = getEnterKeyframes(height, initialStyle)
const options = { duration: animationDuration.value, easing: easingEnter }
const options = { duration: duration.value, easing: props.easingEnter }
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
}
@@ -133,7 +132,7 @@ function leaveTransition(element: Element, done: () => void) {
HTMLElement.style.height = height
HTMLElement.style.overflow = 'hidden'
const keyframes = getEnterKeyframes(height, initialStyle).reverse()
const options = { duration: animationDuration.value, easing: easingLeave }
const options = { duration: duration.value, easing: props.easingLeave }
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-2">
<div class="_content">
<SelectButton
v-model="selectedFilter"
class="filter-type-select"
@@ -16,7 +16,7 @@
auto-filter-focus
/>
</div>
<div class="flex flex-col items-end pt-4">
<div class="_footer">
<Button type="button" @click="submit">{{ $t('g.add') }}</Button>
</div>
</template>
@@ -67,3 +67,15 @@ const submit = () => {
})
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
._content {
@apply flex flex-col space-y-2;
}
._footer {
@apply flex flex-col pt-4 items-end;
}
</style>

View File

@@ -255,6 +255,8 @@ onMounted(() => {
</style>
<style scoped>
@reference "tailwindcss";
.floating-sidebar {
padding: var(--sidebar-padding);
}

View File

@@ -15,7 +15,7 @@
:aria-label="computedTooltip"
@click="emit('click', $event)"
>
<div class="side-bar-button-content flex flex-col items-center gap-2">
<div class="side-bar-button-content">
<slot name="icon">
<div class="sidebar-icon-wrapper relative">
<i
@@ -40,11 +40,9 @@
</span>
</div>
</slot>
<span
v-if="label && !isSmall"
class="side-bar-button-label text-center text-[10px]"
>{{ st(label, label) }}</span
>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
st(label, label)
}}</span>
</div>
</Button>
</template>
@@ -106,6 +104,8 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
</style>
<style scoped>
@reference '../../assets/css/style.css';
.side-bar-button {
width: var(--sidebar-width);
height: var(--sidebar-item-height);
@@ -117,7 +117,12 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
height: var(--sidebar-width);
}
.side-bar-button-content {
@apply flex flex-col items-center gap-2;
}
.side-bar-button-label {
@apply text-[10px] text-center;
line-height: 1;
}

View File

@@ -11,7 +11,6 @@
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
class="min-h-16 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
:pt="sidebarPt"
>
<template #start>
<span class="truncate font-bold" :title="props.title">
@@ -21,7 +20,7 @@
</template>
<template #end>
<div
class="touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
>
<slot name="tool-buttons" />
</div>
@@ -55,10 +54,19 @@ const props = defineProps<{
title: string
class?: string
}>()
const sidebarPt = {
start: 'min-w-0 flex-1 overflow-hidden'
}
const containerRef = ref<HTMLElement | null>(null)
provide(SidebarContainerKey, containerRef)
</script>
<style scoped>
@reference '../../../assets/css/style.css';
:deep(.p-toolbar-end) .p-button {
@apply py-1 2xl:py-2;
}
:deep(.p-toolbar-start) {
@apply min-w-0 flex-1 overflow-hidden;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="container"
class="node-lib-node-container h-full w-full"
class="node-lib-node-container"
data-testid="node-tree-leaf"
:data-node-name="nodeDef.display_name"
>
@@ -206,3 +206,11 @@ onUnmounted(() => {
nodeContentElement.value?.removeEventListener('mouseleave', handleMouseLeave)
})
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
.node-lib-node-container {
@apply h-full w-full;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,17 +16,20 @@ import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
import TopbarBadge from './TopbarBadge.vue'
const {
displayMode = 'full',
reverseOrder = false,
noPadding = false,
backgroundColor = 'var(--comfy-menu-bg)'
} = defineProps<{
displayMode?: 'full' | 'compact' | 'icon-only'
reverseOrder?: boolean
noPadding?: boolean
backgroundColor?: string
}>()
withDefaults(
defineProps<{
displayMode?: 'full' | 'compact' | 'icon-only'
reverseOrder?: boolean
noPadding?: boolean
backgroundColor?: string
}>(),
{
displayMode: 'full',
reverseOrder: false,
noPadding: false,
backgroundColor: 'var(--comfy-menu-bg)'
}
)
const { t } = useI18n()

View File

@@ -113,13 +113,12 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
// Mock the useSubscriptionDialog composable
const mockShowPricingTable = vi.fn()
const mockSubscriptionDialogShow = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: vi.fn(() => ({
show: vi.fn(),
showPricingTable: mockShowPricingTable,
show: mockSubscriptionDialogShow,
hide: vi.fn()
}))
})
@@ -319,8 +318,8 @@ describe('CurrentUserPopoverLegacy', () => {
await plansPricingItem.trigger('click')
// Verify showPricingTable was called
expect(mockShowPricingTable).toHaveBeenCalled()
// Verify subscription dialog show was called
expect(mockSubscriptionDialogShow).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()

Some files were not shown because too many files have changed in this diff Show More