mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-11 00:38:37 +00:00
Compare commits
27 Commits
uy/node-se
...
pysssss/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
307e8a0d04 | ||
|
|
e2c9550664 | ||
|
|
a87e107b01 | ||
|
|
c9f1cc42ad | ||
|
|
6cc1f20bd4 | ||
|
|
1b14f4df8a | ||
|
|
ef93b4696c | ||
|
|
24c512d144 | ||
|
|
6f6141a8e4 | ||
|
|
14f8fdebdd | ||
|
|
73e3aead16 | ||
|
|
c190784307 | ||
|
|
38458c518e | ||
|
|
ed4f7db7f4 | ||
|
|
39157f2375 | ||
|
|
47118ef64f | ||
|
|
f110af79f7 | ||
|
|
8972d27689 | ||
|
|
72d1261983 | ||
|
|
1b90696459 | ||
|
|
e340b5b127 | ||
|
|
68238a5742 | ||
|
|
183a75a218 | ||
|
|
507e667795 | ||
|
|
d617ab1c95 | ||
|
|
b93338cac7 | ||
|
|
fa2d187c83 |
@@ -1,38 +1,18 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from '../src/components/affiliates/affiliateFaqs'
|
||||
import { programDetailRows } from '../src/components/affiliates/programDetails'
|
||||
import type { TranslationKey } from '../src/i18n/translations'
|
||||
import { affiliateFaqs } from '../src/data/affiliateFaq'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const FIRST_FAQ_QUESTION = t(
|
||||
`${AFFILIATE_FAQ_PREFIX}.1.q` as TranslationKey,
|
||||
'en'
|
||||
)
|
||||
const FIRST_FAQ_ANSWER = t(
|
||||
`${AFFILIATE_FAQ_PREFIX}.1.a` as TranslationKey,
|
||||
'en'
|
||||
)
|
||||
const FIRST_PROGRAM_DETAIL_LABEL = t(programDetailRows[0].labelKey, 'en')
|
||||
const FIRST_PROGRAM_DETAIL_VALUE = t(programDetailRows[0].valueKey, 'en')
|
||||
const PROGRAM_DETAIL_TABLE_ROW_COUNT = programDetailRows.length + 1
|
||||
|
||||
const PATH = '/affiliates'
|
||||
const APPLY_URL = 'https://forms.gle/RS8L2ttcuGap4Q1v6'
|
||||
|
||||
const SECTION_TESTIDS = [
|
||||
'affiliate-hero',
|
||||
'affiliate-trust-band',
|
||||
'affiliate-how-it-works',
|
||||
'affiliate-audience',
|
||||
'affiliate-program-details',
|
||||
'affiliate-brand-assets',
|
||||
'affiliate-footer-cta'
|
||||
] as const
|
||||
const TERMS_PATH = '/affiliates/terms'
|
||||
const FAQ_COUNT = affiliateFaqs.length
|
||||
const FIRST_FAQ = affiliateFaqs[0]
|
||||
const HERO_HEADING_TEXT = `${t('affiliate.hero.headingHighlight', 'en')} ${t('affiliate.hero.headingMuted', 'en')}`
|
||||
const CTA_HEADING_TEXT = t('affiliate.cta.heading', 'en')
|
||||
const CTA_APPLY_LABEL = t('affiliate.cta.apply', 'en')
|
||||
const CTA_TERMS_LABEL = t('affiliate.cta.termsLabel', 'en')
|
||||
|
||||
test.describe('Affiliates landing — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -41,32 +21,38 @@ test.describe('Affiliates landing — desktop @smoke', () => {
|
||||
|
||||
test('renders the hero heading and is indexable', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
|
||||
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('renders every page section in top-to-bottom order', async ({
|
||||
page
|
||||
}) => {
|
||||
const ys: number[] = []
|
||||
for (const id of SECTION_TESTIDS) {
|
||||
const section = page.getByTestId(id)
|
||||
await expect(section).toBeVisible()
|
||||
const box = await section.boundingBox()
|
||||
expect(box, `${id} bounding box`).not.toBeNull()
|
||||
ys.push(box!.y)
|
||||
}
|
||||
const sortedYs = [...ys].sort((a, b) => a - b)
|
||||
expect(ys).toEqual(sortedYs)
|
||||
test('renders the closing CTA heading and apply button', async ({ page }) => {
|
||||
const ctaSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
|
||||
})
|
||||
const ctaHeading = ctaSection.getByRole('heading', {
|
||||
level: 2,
|
||||
name: CTA_HEADING_TEXT
|
||||
})
|
||||
await ctaHeading.scrollIntoViewIfNeeded()
|
||||
await expect(ctaHeading).toBeVisible()
|
||||
|
||||
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
|
||||
await expect(applyButton).toBeVisible()
|
||||
await expect(applyButton).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(applyButton).toHaveAttribute('target', '_blank')
|
||||
await expect(applyButton).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
test('renders the program details table on desktop', async ({ page }) => {
|
||||
const table = page.getByTestId('affiliate-program-details-table')
|
||||
await expect(table).toBeVisible()
|
||||
const rows = table.getByRole('row')
|
||||
await expect(rows).toHaveCount(PROGRAM_DETAIL_TABLE_ROW_COUNT)
|
||||
test('CTA section links to the affiliate terms page in the same tab', async ({
|
||||
page
|
||||
}) => {
|
||||
const termsLink = page.getByRole('link', { name: CTA_TERMS_LABEL })
|
||||
await termsLink.scrollIntoViewIfNeeded()
|
||||
await expect(termsLink).toBeVisible()
|
||||
await expect(termsLink).toHaveAttribute('href', TERMS_PATH)
|
||||
await expect(termsLink).not.toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,42 +79,21 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(AFFILIATE_FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('hero and footer CTAs target the application form in a new tab', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroCta = page.getByTestId('affiliate-hero-cta')
|
||||
await expect(heroCta).toBeVisible()
|
||||
await expect(heroCta).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(heroCta).toHaveAttribute('target', '_blank')
|
||||
await expect(heroCta).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
const footerCta = page.getByTestId('affiliate-footer-cta-button')
|
||||
await expect(footerCta).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(footerCta).toHaveAttribute('target', '_blank')
|
||||
await expect(footerCta).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
test('footer links to the affiliate terms page as a same-tab navigation', async ({
|
||||
page
|
||||
}) => {
|
||||
const link = page
|
||||
.getByTestId('affiliate-footer-cta')
|
||||
.getByRole('link', { name: /Read the affiliate program terms/i })
|
||||
await expect(link).toBeVisible()
|
||||
await expect(link).toBeEnabled()
|
||||
await expect(link).toHaveAttribute('href', '/affiliates/terms')
|
||||
await expect(link).not.toHaveAttribute('target', '_blank')
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
const ctaSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
|
||||
})
|
||||
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
|
||||
await applyButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const popupPromise = context.waitForEvent('page')
|
||||
await page.getByTestId('affiliate-hero-cta').click()
|
||||
await applyButton.click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded')
|
||||
const popupUrl = popup.url()
|
||||
@@ -140,12 +105,15 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
})
|
||||
|
||||
test('FAQ items toggle open and closed on click', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', { name: FIRST_FAQ_QUESTION })
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: FIRST_FAQ.question.en
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(page.getByText(FIRST_FAQ_ANSWER)).toBeVisible()
|
||||
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
@@ -157,44 +125,24 @@ test.describe('Affiliates landing — mobile @mobile', () => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading and primary CTA at narrow viewports', async ({
|
||||
page
|
||||
}) => {
|
||||
test('renders the hero heading at narrow viewports', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByTestId('affiliate-hero-cta')).toBeVisible()
|
||||
})
|
||||
|
||||
test('program details collapse to a stacked definition list', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByTestId('affiliate-program-details-table')
|
||||
).toBeHidden()
|
||||
const detailsList = page
|
||||
.getByTestId('affiliate-program-details')
|
||||
.locator('dl')
|
||||
await expect(detailsList).toBeVisible()
|
||||
await expect(
|
||||
detailsList.getByText(FIRST_PROGRAM_DETAIL_LABEL)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
detailsList.getByText(FIRST_PROGRAM_DETAIL_VALUE)
|
||||
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('all major sections remain visible without horizontal overflow', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const id of SECTION_TESTIDS) {
|
||||
const section = page.getByTestId(id)
|
||||
await expect(section).toBeVisible()
|
||||
const box = await section.boundingBox()
|
||||
expect(box, `${id} bounding box`).not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
}
|
||||
test('closing CTA stays within the viewport width', async ({ page }) => {
|
||||
const ctaHeading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: CTA_HEADING_TEXT
|
||||
})
|
||||
await ctaHeading.scrollIntoViewIfNeeded()
|
||||
await expect(ctaHeading).toBeVisible()
|
||||
|
||||
const box = await ctaHeading.boundingBox()
|
||||
expect(box, 'CTA heading bounding box').not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,10 @@ import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const LINUX_UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const IPHONE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
|
||||
test.describe('Download page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -52,6 +56,61 @@ test.describe('Download page @smoke', () => {
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({ userAgent: LINUX_UA })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/download')
|
||||
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
const macBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
|
||||
)
|
||||
await expect(macBtn).toBeVisible()
|
||||
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
).toHaveCount(2)
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
|
||||
const context = await browser.newContext({ userAgent: IPHONE_UA })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/download')
|
||||
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
).toBeVisible()
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('ReasonSection heading and reasons are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
|
||||
|
||||
BIN
apps/website/public/affiliates/brand/comfy-amplified-logo.png
Normal file
BIN
apps/website/public/affiliates/brand/comfy-amplified-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 11.5811L10.2582 18.0581L20 6.05811" stroke="#F2FF59" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 234 B |
@@ -58,7 +58,7 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
runway: { name: 'Runway', slug: 'runway' },
|
||||
vidu: { name: 'Vidu', slug: 'vidu' },
|
||||
bfl: { name: 'Flux (API)', slug: 'flux-api' },
|
||||
grok: { name: 'Grok Image', slug: 'grok-image' },
|
||||
grok: { name: 'Grok Imagine', slug: 'grok-imagine' },
|
||||
stability: { name: 'Stability AI', slug: 'stability-ai' },
|
||||
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
@@ -86,6 +86,20 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
|
||||
}
|
||||
|
||||
// Stub entries that exist only to issue 301 redirects from old slugs to
|
||||
// their new canonical slugs. Keeps renames reproducible across regenerations.
|
||||
const LEGACY_SLUG_REDIRECTS: OutputModel[] = [
|
||||
{
|
||||
slug: 'grok-image',
|
||||
canonicalSlug: 'grok-imagine',
|
||||
name: 'Grok Image',
|
||||
displayName: 'Grok Image',
|
||||
directory: 'partner_nodes',
|
||||
huggingFaceUrl: '',
|
||||
workflowCount: 0
|
||||
}
|
||||
]
|
||||
|
||||
function stripExt(name: string): string {
|
||||
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
|
||||
}
|
||||
@@ -299,7 +313,8 @@ function run(): void {
|
||||
throw new Error(
|
||||
`Failed to parse ${file}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -367,7 +382,7 @@ function run(): void {
|
||||
displayName: m.name
|
||||
}))
|
||||
|
||||
const combined = [...apiOutput, ...output]
|
||||
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
|
||||
|
||||
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
|
||||
process.stdout.write(
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const audienceKeys: TranslationKey[] = [
|
||||
'affiliate-landing.audience.item.0',
|
||||
'affiliate-landing.audience.item.1',
|
||||
'affiliate-landing.audience.item.2',
|
||||
'affiliate-landing.audience.item.3',
|
||||
'affiliate-landing.audience.item.4'
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-audience"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.audience.heading', locale) }}
|
||||
</SectionHeader>
|
||||
<ul class="mx-auto mt-12 flex max-w-3xl flex-col gap-4">
|
||||
<li
|
||||
v-for="key in audienceKeys"
|
||||
:key="key"
|
||||
class="text-primary-comfy-canvas flex items-start gap-4 text-base md:text-lg"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span>{{ t(key, locale) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import { brandAssets } from './brandAssets'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-brand-assets"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.assets.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p
|
||||
class="text-primary-comfy-canvas/70 mx-auto mt-4 max-w-2xl text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.assets.subheading', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
<ul
|
||||
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="asset in brandAssets"
|
||||
:key="asset.id"
|
||||
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col overflow-hidden rounded-4xl border"
|
||||
:data-testid="`affiliate-asset-${asset.id}`"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-comfy-ink/40 flex aspect-video items-center justify-center overflow-hidden p-6"
|
||||
>
|
||||
<img
|
||||
:src="asset.preview"
|
||||
:alt="t(asset.titleKey, locale)"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 p-5">
|
||||
<h3 class="text-primary-comfy-canvas text-base font-light">
|
||||
{{ t(asset.titleKey, locale) }}
|
||||
</h3>
|
||||
<a
|
||||
:href="asset.download"
|
||||
:download="asset.download.split('/').pop()"
|
||||
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
|
||||
>
|
||||
{{ t('affiliate-landing.assets.downloadLabel', locale) }}
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-secondary-mauve/30 border-primary-comfy-canvas/10 mt-12 border-t px-6 py-20 text-center md:py-28"
|
||||
data-testid="affiliate-footer-cta"
|
||||
>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl font-light md:text-4xl lg:text-5xl"
|
||||
>
|
||||
{{ t('affiliate-landing.footerCta.heading', locale) }}
|
||||
</h2>
|
||||
<div class="mt-8 flex flex-col items-center gap-4">
|
||||
<BrandButton
|
||||
:href="externalLinks.affiliateApplicationForm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="lg"
|
||||
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
|
||||
data-testid="affiliate-footer-cta-button"
|
||||
class="px-8 py-4 text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.cta.apply', locale) }}
|
||||
</BrandButton>
|
||||
<a
|
||||
:href="routes.affiliateTerms"
|
||||
class="text-primary-comfy-canvas/70 text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{{ t('affiliate-landing.footerCta.termsLink', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const highlightKeys: TranslationKey[] = [
|
||||
'affiliate-landing.hero.highlight.0',
|
||||
'affiliate-landing.hero.highlight.1',
|
||||
'affiliate-landing.hero.highlight.2',
|
||||
'affiliate-landing.hero.highlight.3'
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-stretch gap-10 px-6 pt-12 pb-16 lg:flex-row lg:items-center lg:gap-16 lg:px-20 lg:pt-20 lg:pb-24"
|
||||
data-testid="affiliate-hero"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
|
||||
>
|
||||
{{ t('affiliate-landing.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-primary-comfy-yellow mt-4 text-2xl font-light md:text-3xl lg:text-4xl"
|
||||
>
|
||||
{{ t('affiliate-landing.hero.subheading', locale) }}
|
||||
</p>
|
||||
<p class="text-primary-comfy-canvas/80 mt-6 max-w-xl text-base">
|
||||
{{ t('affiliate-landing.hero.body', locale) }}
|
||||
</p>
|
||||
<ul class="mt-6 flex flex-col gap-3">
|
||||
<li
|
||||
v-for="key in highlightKeys"
|
||||
:key="key"
|
||||
class="text-primary-comfy-canvas flex items-start gap-3 text-base"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span>{{ t(key, locale) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-8">
|
||||
<BrandButton
|
||||
:href="externalLinks.affiliateApplicationForm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="lg"
|
||||
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
|
||||
data-testid="affiliate-hero-cta"
|
||||
class="px-8 py-4 text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.cta.apply', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center"
|
||||
data-testid="affiliate-hero-media"
|
||||
>
|
||||
<slot name="media">
|
||||
<video
|
||||
src="https://media.comfy.org/website/homepage/showcase/ui-overview.webm"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
class="w-full max-w-xl rounded-4xl"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const steps: { titleKey: TranslationKey; bodyKey: TranslationKey }[] = [
|
||||
{
|
||||
titleKey: 'affiliate-landing.how.step.0.title',
|
||||
bodyKey: 'affiliate-landing.how.step.0.body'
|
||||
},
|
||||
{
|
||||
titleKey: 'affiliate-landing.how.step.1.title',
|
||||
bodyKey: 'affiliate-landing.how.step.1.body'
|
||||
},
|
||||
{
|
||||
titleKey: 'affiliate-landing.how.step.2.title',
|
||||
bodyKey: 'affiliate-landing.how.step.2.body'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-how-it-works"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.how.heading', locale) }}
|
||||
</SectionHeader>
|
||||
<ol
|
||||
class="mx-auto mt-12 grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3 md:gap-8"
|
||||
>
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.titleKey"
|
||||
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col gap-4 rounded-4xl border p-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<h3 class="text-primary-comfy-canvas text-2xl font-light">
|
||||
{{ t(step.titleKey, locale) }}
|
||||
</h3>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm">
|
||||
{{ t(step.bodyKey, locale) }}
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import { programDetailRows } from './programDetails'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-program-details"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.details.heading', locale) }}
|
||||
</SectionHeader>
|
||||
<div class="mx-auto mt-12 max-w-3xl">
|
||||
<div class="hidden md:block">
|
||||
<table
|
||||
class="w-full border-collapse text-left"
|
||||
data-testid="affiliate-program-details-table"
|
||||
>
|
||||
<thead>
|
||||
<tr class="border-primary-comfy-canvas/20 border-b">
|
||||
<th
|
||||
scope="col"
|
||||
class="text-primary-comfy-yellow py-4 pr-6 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('affiliate-landing.details.headerLabel', locale) }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="text-primary-comfy-yellow py-4 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('affiliate-landing.details.headerValue', locale) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in programDetailRows"
|
||||
:key="row.labelKey"
|
||||
class="border-primary-comfy-canvas/10 border-b"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
class="text-primary-comfy-canvas py-5 pr-6 text-base font-light"
|
||||
>
|
||||
{{ t(row.labelKey, locale) }}
|
||||
</th>
|
||||
<td class="text-primary-comfy-canvas/80 py-5 text-base">
|
||||
{{ t(row.valueKey, locale) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<dl class="flex flex-col gap-6 md:hidden">
|
||||
<div
|
||||
v-for="row in programDetailRows"
|
||||
:key="row.labelKey"
|
||||
class="border-primary-comfy-canvas/10 flex flex-col gap-1 border-b pb-4"
|
||||
>
|
||||
<dt
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(row.labelKey, locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas text-base">
|
||||
{{ t(row.valueKey, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="border-primary-comfy-canvas/10 border-y px-6 py-10 text-center md:py-12"
|
||||
data-testid="affiliate-trust-band"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-canvas text-sm tracking-wider uppercase md:text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.trust.label', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
export const AFFILIATE_FAQ_PREFIX = 'affiliate-landing.faq'
|
||||
export const AFFILIATE_FAQ_HEADING_KEY: TranslationKey =
|
||||
'affiliate-landing.faq.heading'
|
||||
export const AFFILIATE_FAQ_COUNT = 8
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasKey, t, translationKeys } from '../../i18n/translations'
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_HEADING_KEY,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from './affiliateFaqs'
|
||||
import { brandAssets } from './brandAssets'
|
||||
|
||||
const PREFIX = 'affiliate-landing'
|
||||
|
||||
const EXPECTED_SECTION_PREFIXES = [
|
||||
'page',
|
||||
'cta',
|
||||
'hero',
|
||||
'trust',
|
||||
'how',
|
||||
'audience',
|
||||
'details',
|
||||
'assets',
|
||||
'faq',
|
||||
'footerCta'
|
||||
] as const
|
||||
|
||||
const HERO_HIGHLIGHT_COUNT = 4
|
||||
const HOW_STEP_COUNT = 3
|
||||
const AUDIENCE_ITEM_COUNT = 5
|
||||
const DETAILS_ROW_COUNT = 6
|
||||
|
||||
const INTERNAL_KEY_PATTERNS = [
|
||||
/open-questions/,
|
||||
/todo/i,
|
||||
/draft/i,
|
||||
/placeholder/i,
|
||||
/internal/i
|
||||
]
|
||||
|
||||
function affiliateKeys(): string[] {
|
||||
return translationKeys.filter((k) => k.startsWith(`${PREFIX}.`))
|
||||
}
|
||||
|
||||
describe('affiliate landing i18n', () => {
|
||||
it('exposes the canonical top-level section prefixes', () => {
|
||||
const keys = affiliateKeys()
|
||||
for (const section of EXPECTED_SECTION_PREFIXES) {
|
||||
const hit = keys.some((k) => k.startsWith(`${PREFIX}.${section}.`))
|
||||
expect(hit, `missing section: ${section}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('orders sections as the page renders them', () => {
|
||||
const keys = affiliateKeys()
|
||||
const seenSections: string[] = []
|
||||
for (const key of keys) {
|
||||
const section = key.split('.')[1]
|
||||
if (!section) continue
|
||||
if (!seenSections.includes(section)) seenSections.push(section)
|
||||
}
|
||||
const orderedExpected = EXPECTED_SECTION_PREFIXES.filter((s) =>
|
||||
seenSections.includes(s)
|
||||
)
|
||||
const orderedActual = seenSections.filter((s) =>
|
||||
(EXPECTED_SECTION_PREFIXES as readonly string[]).includes(s)
|
||||
)
|
||||
expect(orderedActual).toEqual([...orderedExpected])
|
||||
})
|
||||
|
||||
it('exposes hero, page, and cta keys editors will need', () => {
|
||||
expect(hasKey(`${PREFIX}.page.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.page.description`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.cta.apply`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.cta.applyAriaLabel`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.hero.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.hero.subheading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.hero.body`)).toBe(true)
|
||||
for (let i = 0; i < HERO_HIGHLIGHT_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.hero.highlight.${i}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the trust band, how-it-works, and audience copy', () => {
|
||||
expect(hasKey(`${PREFIX}.trust.label`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.how.heading`)).toBe(true)
|
||||
for (let i = 0; i < HOW_STEP_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.how.step.${i}.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.how.step.${i}.body`)).toBe(true)
|
||||
}
|
||||
expect(hasKey(`${PREFIX}.audience.heading`)).toBe(true)
|
||||
for (let i = 0; i < AUDIENCE_ITEM_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.audience.item.${i}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the program details rows', () => {
|
||||
expect(hasKey(`${PREFIX}.details.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.details.headerLabel`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.details.headerValue`)).toBe(true)
|
||||
for (let i = 0; i < DETAILS_ROW_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.details.row.${i}.label`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.details.row.${i}.value`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches every brand-asset tile to a translation key', () => {
|
||||
expect(hasKey(`${PREFIX}.assets.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.assets.subheading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.assets.downloadLabel`)).toBe(true)
|
||||
for (const asset of brandAssets) {
|
||||
expect(hasKey(asset.titleKey)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes every 1-indexed faq.<n>.q/a pair from 1 to AFFILIATE_FAQ_COUNT (FAQSection contract)', () => {
|
||||
expect(AFFILIATE_FAQ_PREFIX).toBe(`${PREFIX}.faq`)
|
||||
expect(hasKey(AFFILIATE_FAQ_HEADING_KEY)).toBe(true)
|
||||
for (let n = 1; n <= AFFILIATE_FAQ_COUNT; n++) {
|
||||
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.q`)).toBe(true)
|
||||
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.a`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps AFFILIATE_FAQ_COUNT in sync with the actual faq.<n>.q keys in translations', () => {
|
||||
const faqQuestionKeyPattern = new RegExp(
|
||||
`^${AFFILIATE_FAQ_PREFIX}\\.(\\d+)\\.q$`
|
||||
)
|
||||
const indices = translationKeys
|
||||
.map((k) => k.match(faqQuestionKeyPattern)?.[1])
|
||||
.filter((m): m is string => m !== undefined)
|
||||
.map((s) => parseInt(s, 10))
|
||||
.sort((a, b) => a - b)
|
||||
expect(indices).toEqual(
|
||||
Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => i + 1)
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes the footer cta copy', () => {
|
||||
expect(hasKey(`${PREFIX}.footerCta.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.footerCta.termsLink`)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns non-empty english copy for every affiliate-landing key', () => {
|
||||
for (const key of affiliateKeys()) {
|
||||
expect(t(key as never, 'en').trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not leak internal-only keys (drafts, todos, open questions)', () => {
|
||||
const leaks = affiliateKeys().filter((k) =>
|
||||
INTERNAL_KEY_PATTERNS.some((re) => re.test(k))
|
||||
)
|
||||
expect(leaks).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
interface BrandAsset {
|
||||
id: string
|
||||
titleKey: TranslationKey
|
||||
download: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
export const brandAssets: BrandAsset[] = [
|
||||
{
|
||||
id: 'logo-horizontal',
|
||||
titleKey: 'affiliate-landing.assets.tile.logo-horizontal.title',
|
||||
download: '/icons/logo.svg',
|
||||
preview: '/icons/logo.svg'
|
||||
},
|
||||
{
|
||||
id: 'logomark',
|
||||
titleKey: 'affiliate-landing.assets.tile.logomark.title',
|
||||
download: '/icons/logomark.svg',
|
||||
preview: '/icons/logomark.svg'
|
||||
},
|
||||
{
|
||||
id: 'comfy-full-logo-yellow',
|
||||
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-yellow.title',
|
||||
download: '/affiliates/brand/comfy-full-logo-yellow.svg',
|
||||
preview: '/affiliates/brand/comfy-full-logo-yellow.svg'
|
||||
},
|
||||
{
|
||||
id: 'comfy-full-logo-ink',
|
||||
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-ink.title',
|
||||
download: '/affiliates/brand/comfy-full-logo-ink.svg',
|
||||
preview: '/affiliates/brand/comfy-full-logo-ink.svg'
|
||||
},
|
||||
{
|
||||
id: 'amplified-logo-mark',
|
||||
titleKey: 'affiliate-landing.assets.tile.amplified-logo-mark.title',
|
||||
download: '/affiliates/brand/comfy-amplified-logo-mark.svg',
|
||||
preview: '/affiliates/brand/comfy-amplified-logo-mark.svg'
|
||||
},
|
||||
{
|
||||
id: 'dimensional-logo-mark',
|
||||
titleKey: 'affiliate-landing.assets.tile.dimensional-logo-mark.title',
|
||||
download: '/affiliates/brand/comfy-dimensional-logo-mark.svg',
|
||||
preview: '/affiliates/brand/comfy-dimensional-logo-mark.svg'
|
||||
},
|
||||
{
|
||||
id: 'color-combo-yellow',
|
||||
titleKey: 'affiliate-landing.assets.tile.color-combo-yellow.title',
|
||||
download: '/affiliates/brand/comfy-color-combo-yellow.svg',
|
||||
preview: '/affiliates/brand/comfy-color-combo-yellow.svg'
|
||||
},
|
||||
{
|
||||
id: 'color-combo-ink',
|
||||
titleKey: 'affiliate-landing.assets.tile.color-combo-ink.title',
|
||||
download: '/affiliates/brand/comfy-color-combo-ink.svg',
|
||||
preview: '/affiliates/brand/comfy-color-combo-ink.svg'
|
||||
}
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
interface ProgramDetailRow {
|
||||
labelKey: TranslationKey
|
||||
valueKey: TranslationKey
|
||||
}
|
||||
|
||||
export const programDetailRows: ProgramDetailRow[] = [
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.0.label',
|
||||
valueKey: 'affiliate-landing.details.row.0.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.1.label',
|
||||
valueKey: 'affiliate-landing.details.row.1.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.2.label',
|
||||
valueKey: 'affiliate-landing.details.row.2.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.3.label',
|
||||
valueKey: 'affiliate-landing.details.row.3.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.4.label',
|
||||
valueKey: 'affiliate-landing.details.row.4.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.5.label',
|
||||
valueKey: 'affiliate-landing.details.row.5.value'
|
||||
}
|
||||
]
|
||||
60
apps/website/src/components/blocks/BenefitsGrid01.vue
Normal file
60
apps/website/src/components/blocks/BenefitsGrid01.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
|
||||
type Benefit = { id: string; description: string }
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
benefits: readonly Benefit[]
|
||||
primaryCta?: Cta
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mx-auto max-w-7xl">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<article
|
||||
v-for="(benefit, index) in benefits"
|
||||
:key="benefit.id"
|
||||
class="flex flex-col gap-6 rounded-4xl bg-primary-comfy-ink p-6 lg:p-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow font-mono text-sm font-bold tracking-wide"
|
||||
>
|
||||
{{ String(index + 1).padStart(2, '0') }}
|
||||
</span>
|
||||
<p
|
||||
class="text-base/relaxed font-medium text-primary-comfy-canvas lg:text-xl"
|
||||
>
|
||||
{{ benefit.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div v-if="primaryCta" class="mt-10 flex justify-center lg:mt-12">
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
size="lg"
|
||||
class="px-20 py-4 text-base uppercase"
|
||||
variant="outline"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
65
apps/website/src/components/blocks/BrandAssetsGrid01.vue
Normal file
65
apps/website/src/components/blocks/BrandAssetsGrid01.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
type Asset = {
|
||||
id: string
|
||||
title: string
|
||||
download: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
subheading: string
|
||||
downloadLabel: string
|
||||
assets: readonly Asset[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="mx-auto max-w-6xl text-center">
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-base text-primary-comfy-canvas/70">
|
||||
{{ subheading }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
class="bg-transparency-white-t4 flex flex-col overflow-hidden rounded-4xl border border-primary-comfy-canvas/10"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-video items-center justify-center overflow-hidden bg-primary-comfy-ink/40 p-6"
|
||||
>
|
||||
<img
|
||||
:src="asset.preview"
|
||||
:alt="asset.title"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 p-5">
|
||||
<h3 class="text-base font-light text-primary-comfy-canvas">
|
||||
{{ asset.title }}
|
||||
</h3>
|
||||
<a
|
||||
:href="asset.download"
|
||||
:download="asset.download.split('/').pop()"
|
||||
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
|
||||
>
|
||||
{{ downloadLabel }}
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
56
apps/website/src/components/blocks/ChecklistSplit01.vue
Normal file
56
apps/website/src/components/blocks/ChecklistSplit01.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
type Criterion = { id: string; label: string }
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
subheading: string
|
||||
eyebrow?: string
|
||||
criteria: readonly Criterion[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<GlassCard class="px-6 py-10 lg:px-16 lg:py-14">
|
||||
<div
|
||||
class="grid grid-cols-1 items-center gap-10 lg:grid-cols-2 lg:gap-16"
|
||||
>
|
||||
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-4xl">
|
||||
{{ subheading }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<span
|
||||
v-if="eyebrow"
|
||||
class="text-xs font-bold tracking-widest text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ eyebrow }}
|
||||
</span>
|
||||
<ul class="flex flex-col gap-4">
|
||||
<li
|
||||
v-for="criterion in criteria"
|
||||
:key="criterion.id"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<CheckIcon
|
||||
class="text-primary-comfy-yellow mt-0.5 size-5 shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-primary-comfy-canvas lg:text-base">
|
||||
{{ criterion.label }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
50
apps/website/src/components/blocks/CtaCenter01.vue
Normal file
50
apps/website/src/components/blocks/CtaCenter01.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
type TermsLink = {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
primaryCta: Cta
|
||||
termsLink: TermsLink
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
|
||||
<a
|
||||
:href="termsLink.href"
|
||||
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
|
||||
>
|
||||
{{ termsLink.label }}
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
94
apps/website/src/components/blocks/FAQSplit01.vue
Normal file
94
apps/website/src/components/blocks/FAQSplit01.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
const { faqs } = defineProps<{
|
||||
heading: string
|
||||
faqs: readonly Faq[]
|
||||
}>()
|
||||
|
||||
const expanded = reactive<boolean[]>(faqs.map(() => false))
|
||||
|
||||
watch(
|
||||
() => faqs.length,
|
||||
(length) => {
|
||||
if (length === expanded.length) return
|
||||
expanded.length = 0
|
||||
for (let i = 0; i < length; i += 1) expanded.push(false)
|
||||
}
|
||||
)
|
||||
|
||||
function toggle(index: number) {
|
||||
expanded[index] = !expanded[index]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
type="button"
|
||||
:aria-expanded="expanded[index]"
|
||||
:aria-controls="`faq-panel-${faq.id}`"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center justify-between text-left',
|
||||
index === 0 ? 'pb-6' : 'py-6'
|
||||
)
|
||||
"
|
||||
@click="toggle(index)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-light md:text-xl',
|
||||
expanded[index]
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ faq.question }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ expanded[index] ? '−' : '+' }}
|
||||
</span>
|
||||
</button>
|
||||
<section
|
||||
v-show="expanded[index]"
|
||||
:id="`faq-panel-${faq.id}`"
|
||||
role="region"
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
153
apps/website/src/components/blocks/HeroSplit01.vue
Normal file
153
apps/website/src/components/blocks/HeroSplit01.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
type VideoTrack = {
|
||||
src: string
|
||||
kind: 'subtitles' | 'captions' | 'descriptions'
|
||||
srclang: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
titleHighlight,
|
||||
features = [],
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
imageSrc,
|
||||
imageAlt = '',
|
||||
imageWidth = 800,
|
||||
imageHeight = 600,
|
||||
imagePosition = 'right',
|
||||
videoSrc,
|
||||
videoPoster,
|
||||
videoTracks = [],
|
||||
videoAutoplay = false,
|
||||
videoLoop = false,
|
||||
videoMinimal = false,
|
||||
videoHideControls = false
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
badgeText: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
titleHighlight?: string
|
||||
features?: string[]
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
imageSrc?: string
|
||||
imageAlt?: string
|
||||
imageWidth?: number
|
||||
imageHeight?: number
|
||||
imagePosition?: 'left' | 'right'
|
||||
videoSrc?: string
|
||||
videoPoster?: string
|
||||
videoTracks?: VideoTrack[]
|
||||
videoAutoplay?: boolean
|
||||
videoLoop?: boolean
|
||||
videoMinimal?: boolean
|
||||
videoHideControls?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="
|
||||
cn(
|
||||
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="w-full lg:flex-1">
|
||||
<ProductHeroBadge
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
>
|
||||
<template v-if="titleHighlight">
|
||||
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
|
||||
{{ title }}
|
||||
</template>
|
||||
<template v-else>{{ title }}</template>
|
||||
</h1>
|
||||
|
||||
<ul v-if="features.length" class="mt-8 space-y-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-start gap-3 text-base text-primary-comfy-canvas"
|
||||
>
|
||||
<CheckIcon class="text-primary-comfy-yellow mt-1 size-5 shrink-0" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
size="lg"
|
||||
class="px-8 py-4 text-base uppercase"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
v-if="secondaryCta"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="px-8 py-4 text-base uppercase"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-first w-full lg:order-last lg:flex-1">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
91
apps/website/src/components/blocks/StepsGrid01.vue
Normal file
91
apps/website/src/components/blocks/StepsGrid01.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
|
||||
|
||||
type Step = { id: string; label: string; description: string }
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
steps: readonly Step[]
|
||||
}>()
|
||||
|
||||
const isRtlRow = (i: number) => Math.floor(i / 2) % 2 === 1
|
||||
const isFullSpan = (i: number, total: number) =>
|
||||
i === total - 1 && total % 2 === 1
|
||||
|
||||
function hasHorizontalConnector(i: number, total: number) {
|
||||
if (isFullSpan(i, total)) return false
|
||||
if (!isRtlRow(i) && i % 2 === 0 && i + 1 < total) return true
|
||||
if (isRtlRow(i) && i % 2 === 1) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function hasMobileVertical(i: number, total: number) {
|
||||
return i < total - 1
|
||||
}
|
||||
|
||||
function hasLgVertical(i: number, total: number) {
|
||||
return i % 2 === 1 && i + 1 < total
|
||||
}
|
||||
|
||||
function cardClass(i: number, total: number) {
|
||||
const fullSpan = isFullSpan(i, total)
|
||||
const rtl = isRtlRow(i)
|
||||
return cn(
|
||||
'border-primary-comfy-yellow relative rounded-3xl border-2 p-8 lg:p-10',
|
||||
fullSpan && 'lg:col-span-2',
|
||||
!fullSpan && rtl && i % 2 === 0 && 'lg:col-start-2',
|
||||
!fullSpan && rtl && i % 2 === 1 && 'lg:col-start-1'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="mx-auto grid max-w-3xl grid-cols-1 gap-4 lg:grid-flow-dense lg:grid-cols-2"
|
||||
>
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
:class="cardClass(index, steps.length)"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow font-formula-narrow inline-block -skew-x-12 rounded-sm px-3 py-1.5 text-sm font-bold tracking-wide text-primary-comfy-ink uppercase lg:text-base"
|
||||
>
|
||||
<span class="inline-block skew-x-12">
|
||||
{{ index + 1 }}. {{ step.label }}
|
||||
</span>
|
||||
</span>
|
||||
<p class="mt-6 text-sm/relaxed text-primary-comfy-canvas lg:text-base">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
|
||||
<NodeUnionIcon
|
||||
v-if="hasHorizontalConnector(index, steps.length)"
|
||||
class="text-primary-comfy-yellow absolute top-1/2 right-0 hidden size-4 translate-x-[calc(100%+2px)] -translate-y-1/2 scale-x-150 rotate-90 lg:block"
|
||||
/>
|
||||
<NodeUnionIcon
|
||||
v-if="
|
||||
hasMobileVertical(index, steps.length) ||
|
||||
hasLgVertical(index, steps.length)
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-comfy-yellow absolute bottom-0 left-1/2 size-4 -translate-x-1/2 translate-y-[calc(100%+2px)] scale-x-150',
|
||||
!hasMobileVertical(index, steps.length) && 'hidden lg:block',
|
||||
!hasLgVertical(index, steps.length) && 'lg:hidden'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import WireNodeLayout from '../common/WireNodeLayout.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const reasons = [
|
||||
const reasons: TranslationKey[] = [
|
||||
'careers.whyJoin.reason1',
|
||||
'careers.whyJoin.reason2',
|
||||
'careers.whyJoin.reason3',
|
||||
'careers.whyJoin.reason4',
|
||||
'careers.whyJoin.reason5'
|
||||
] as const
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { BrandButtonVariants } from './brandButton.variants'
|
||||
import { brandButtonVariants } from './brandButton.variants'
|
||||
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
variant,
|
||||
size,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
const props = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
@@ -21,15 +15,25 @@ const {
|
||||
size?: BrandButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const resolvedRel = computed(
|
||||
() =>
|
||||
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href
|
||||
:target
|
||||
:rel
|
||||
:class="cn(brandButtonVariants({ variant, size }), customClass)"
|
||||
:is="props.href ? 'a' : 'button'"
|
||||
:href="props.href"
|
||||
:target="props.target"
|
||||
:rel="resolvedRel"
|
||||
:class="
|
||||
cn(
|
||||
brandButtonVariants({ variant: props.variant, size: props.size }),
|
||||
props.class ?? ''
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
<slot />
|
||||
|
||||
@@ -28,14 +28,18 @@ const {
|
||||
poster,
|
||||
tracks = [],
|
||||
autoplay = false,
|
||||
minimal = false
|
||||
loop = false,
|
||||
minimal = false,
|
||||
hideControls = false
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
src?: string
|
||||
poster?: string
|
||||
tracks?: VideoTrack[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
}>()
|
||||
|
||||
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
|
||||
@@ -200,8 +204,9 @@ function toggleFullscreen() {
|
||||
crossorigin="anonymous"
|
||||
playsinline
|
||||
:autoplay
|
||||
:loop
|
||||
muted
|
||||
@click="playing = !playing"
|
||||
@click="hideControls ? undefined : (playing = !playing)"
|
||||
>
|
||||
<track
|
||||
v-for="track in tracks"
|
||||
@@ -215,7 +220,7 @@ function toggleFullscreen() {
|
||||
|
||||
<!-- Minimal centered play/pause button -->
|
||||
<div
|
||||
v-if="minimal && src"
|
||||
v-if="minimal && src && !hideControls"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 flex items-center justify-center transition-opacity duration-300',
|
||||
@@ -235,7 +240,7 @@ function toggleFullscreen() {
|
||||
|
||||
<!-- Bottom control bar -->
|
||||
<div
|
||||
v-if="src && !minimal"
|
||||
v-if="src && !minimal && !hideControls"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-0 bottom-0 flex items-center gap-3 p-4 transition-opacity duration-300 lg:px-6 lg:py-5',
|
||||
@@ -285,7 +290,7 @@ function toggleFullscreen() {
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg
|
||||
class="text-primary-comfy-ink size-3.5 lg:size-4"
|
||||
class="size-3.5 text-primary-comfy-ink lg:size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -331,7 +336,7 @@ function toggleFullscreen() {
|
||||
<!-- Muted icon -->
|
||||
<svg
|
||||
v-if="muted"
|
||||
class="text-primary-comfy-ink size-3.5 lg:size-4"
|
||||
class="size-3.5 text-primary-comfy-ink lg:size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
@@ -349,7 +354,7 @@ function toggleFullscreen() {
|
||||
<!-- Unmuted icon -->
|
||||
<svg
|
||||
v-else
|
||||
class="text-primary-comfy-ink size-3.5 lg:size-4"
|
||||
class="size-3.5 text-primary-comfy-ink lg:size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -7,12 +7,16 @@ const {
|
||||
item,
|
||||
locale = 'en',
|
||||
aspect = 'var(--aspect-ratio-gallery-card)',
|
||||
mobile = false
|
||||
mobile = false,
|
||||
objectPosition = 'center',
|
||||
objectFit = 'cover'
|
||||
} = defineProps<{
|
||||
item: GalleryItem
|
||||
locale?: Locale
|
||||
aspect?: string
|
||||
mobile?: boolean
|
||||
objectPosition?: string
|
||||
objectFit?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
@@ -31,13 +35,15 @@ defineEmits<{ click: [] }>()
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
class="size-full transition-transform duration-300 group-hover:scale-105"
|
||||
:style="{ objectPosition, objectFit }"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="item.image"
|
||||
:alt="item.title"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
class="size-full transition-transform duration-300 group-hover:scale-105"
|
||||
:style="{ objectPosition, objectFit }"
|
||||
/>
|
||||
<!-- Desktop hover overlay -->
|
||||
<div
|
||||
|
||||
23
apps/website/src/components/icons/CheckIcon.vue
Normal file
23
apps/website/src/components/icons/CheckIcon.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
:class="$props.class"
|
||||
>
|
||||
<path
|
||||
d="M5 11.5811L10.2582 18.0581L20 6.05811"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
32
apps/website/src/components/icons/NodeUnionIcon.vue
Normal file
32
apps/website/src/components/icons/NodeUnionIcon.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const clipId = `node-union-icon-clip-${useId()}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
:class="$props.class"
|
||||
>
|
||||
<g :clip-path="`url(#${clipId})`">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M-1.59144e-05 0H100V100H-1.59144e-05V0ZM32.3741 50C32.3741 77.0727 16.2692 99.0196 -3.59714 99.0196C-23.4635 99.0196 -39.5684 77.0727 -39.5684 50C-39.5684 22.9273 -23.4635 0.980392 -3.59714 0.980392C16.2692 0.980392 32.3741 22.9273 32.3741 50ZM139.568 50C139.568 77.0727 123.463 99.0196 103.597 99.0196C83.7309 99.0196 67.6259 77.0727 67.6259 50C67.6259 22.9273 83.7309 0.980392 103.597 0.980392C123.463 0.980392 139.568 22.9273 139.568 50Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath :id="clipId">
|
||||
<rect width="100" height="100" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -21,7 +21,7 @@ const demoVideoPoster =
|
||||
<div class="flex flex-col gap-8">
|
||||
<div>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
class="text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.featured.title', locale) }}
|
||||
</h2>
|
||||
@@ -31,7 +31,7 @@ const demoVideoPoster =
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
|
||||
class="max-w-md text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t('learning.featured.description', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
|
||||
|
||||
import type { LearningTutorial } from '../../data/learningTutorials'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { t } from '../../i18n/translations'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { tutorial, locale = 'en' } = defineProps<{
|
||||
tutorial: LearningTutorial
|
||||
@@ -15,21 +16,6 @@ const { tutorial, locale = 'en' } = defineProps<{
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
|
||||
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
|
||||
|
||||
const playFromStart = () => {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
video.currentTime = 0
|
||||
void video.play().catch(() => {})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => tutorial.id,
|
||||
() => {
|
||||
playFromStart()
|
||||
}
|
||||
)
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) emit('close')
|
||||
@@ -42,7 +28,6 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
onMounted(() => {
|
||||
lockScroll()
|
||||
dialogRef.value?.showModal()
|
||||
playFromStart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -62,31 +47,30 @@ onUnmounted(() => {
|
||||
>
|
||||
<button
|
||||
:aria-label="t('gallery.detail.close', locale)"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
|
||||
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:right-26"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
|
||||
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
|
||||
style="mask: url('/icons/close.svg') center / contain no-repeat"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
|
||||
class="border-primary-comfy-yellow rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-3 lg:p-4"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
<VideoPlayer
|
||||
:key="tutorial.id"
|
||||
:locale
|
||||
:src="tutorial.videoSrc"
|
||||
:poster="tutorial.poster"
|
||||
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
|
||||
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
|
||||
@@ -22,7 +22,7 @@ const activeTutorial = () =>
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
|
||||
class="mb-12 text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ t('learning.tutorials.heading', locale) }}
|
||||
</h2>
|
||||
@@ -71,9 +71,9 @@ const activeTutorial = () =>
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
|
||||
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<MaskRevealButton
|
||||
|
||||
152
apps/website/src/components/models/ModelCreationsSection.vue
Normal file
152
apps/website/src/components/models/ModelCreationsSection.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryCard from '../gallery/GalleryCard.vue'
|
||||
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const modelName = 'Grok'
|
||||
const ctaHref = 'https://comfy.org/workflows/model/grok'
|
||||
|
||||
const items: GalleryItem[] = [
|
||||
{
|
||||
id: 'subway-swan',
|
||||
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
|
||||
title: 'Subway Swan',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'milos-little-wonder',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
|
||||
title: 'Milos Little Wonder',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'amber-passage',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
|
||||
title: 'Amber Passage',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats',
|
||||
objectPosition: 'bottom'
|
||||
},
|
||||
{
|
||||
id: 'neon-revenant',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
|
||||
title: 'Neon Revenant',
|
||||
userAlias: 'Eric Solorio',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.instagram.com/enigmatic_e'
|
||||
},
|
||||
{
|
||||
id: 'midnight-umami',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
|
||||
title: 'Midnight Umami',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
}
|
||||
]
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const modalIndex = ref(0)
|
||||
|
||||
function openDetail(index: number) {
|
||||
modalIndex.value = index
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
const title = t('models.list.creations.title', locale).replace(
|
||||
'{name}',
|
||||
modelName
|
||||
)
|
||||
const ctaLabel = t('models.list.creations.cta', locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-testid="model-creations"
|
||||
class="flex flex-col items-center px-4 py-16 lg:px-20 lg:pt-36"
|
||||
>
|
||||
<h2
|
||||
class="max-w-4xl text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-16 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ ctaLabel }}
|
||||
</BrandButton>
|
||||
|
||||
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(0, 2)"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(2, 5)"
|
||||
:key="i + 2"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
@click="openDetail(i + 2)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 max-lg:pb-6 lg:hidden"
|
||||
>
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
mobile
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GalleryDetailModal
|
||||
v-if="modalOpen"
|
||||
:items
|
||||
:initial-index="modalIndex"
|
||||
:locale
|
||||
@close="modalOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
62
apps/website/src/components/models/ModelsHeroSection.vue
Normal file
62
apps/website/src/components/models/ModelsHeroSection.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
modelName,
|
||||
ctaHref,
|
||||
videoSrc,
|
||||
videoAriaLabel
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
modelName: string
|
||||
ctaHref: string
|
||||
videoSrc: string
|
||||
videoAriaLabel?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col items-center px-6 pt-16 text-center lg:pt-36">
|
||||
<h1
|
||||
class="max-w-4xl text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{
|
||||
t('models.list.heroTitle.before', locale).replace('{name}', modelName)
|
||||
}}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span>
|
||||
{{
|
||||
t('models.list.heroTitle.after', locale).replace('{name}', modelName)
|
||||
}}
|
||||
</h1>
|
||||
<p
|
||||
class="mt-6 max-w-2xl text-sm text-pretty text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-10 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
|
||||
</BrandButton>
|
||||
<div class="mt-16 w-full max-w-5xl">
|
||||
<video
|
||||
:src="videoSrc"
|
||||
:aria-label="videoAriaLabel || undefined"
|
||||
:aria-hidden="videoAriaLabel ? undefined : true"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="rounded-4.5xl size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
@@ -94,12 +95,9 @@ const features: IncludedFeature[] = [
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<img
|
||||
<CheckIcon
|
||||
v-else
|
||||
src="/icons/check.svg"
|
||||
alt=""
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<p class="text-primary-comfy-canvas text-sm font-medium">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
|
||||
import {
|
||||
downloadUrls,
|
||||
useDownloadUrl
|
||||
} from '../../../composables/useDownloadUrl'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
|
||||
@@ -12,32 +15,64 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { downloadUrl, platform } = useDownloadUrl()
|
||||
const { downloadUrl, platform, showFallback } = useDownloadUrl()
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
switch (platform.value) {
|
||||
case 'mac':
|
||||
return '/icons/os/apple.svg'
|
||||
case 'windows':
|
||||
return '/icons/os/windows.svg'
|
||||
default:
|
||||
return undefined
|
||||
const ICONS = {
|
||||
windows: '/icons/os/windows.svg',
|
||||
mac: '/icons/os/apple.svg'
|
||||
} as const
|
||||
|
||||
interface ButtonSpec {
|
||||
key: string
|
||||
href: string
|
||||
icon: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
const buttons = computed<ButtonSpec[]>(() => {
|
||||
if (platform.value) {
|
||||
return [
|
||||
{
|
||||
key: platform.value,
|
||||
href: downloadUrl.value,
|
||||
icon: ICONS[platform.value]
|
||||
}
|
||||
]
|
||||
}
|
||||
if (showFallback.value) {
|
||||
const label = t('download.hero.downloadLocal', locale)
|
||||
return [
|
||||
{
|
||||
key: 'windows',
|
||||
href: downloadUrls.windows,
|
||||
icon: ICONS.windows,
|
||||
ariaLabel: `${label} — Windows`
|
||||
},
|
||||
{
|
||||
key: 'mac',
|
||||
href: downloadUrls.macArm,
|
||||
icon: ICONS.mac,
|
||||
ariaLabel: `${label} — macOS`
|
||||
}
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BrandButton
|
||||
v-show="platform"
|
||||
:href="downloadUrl"
|
||||
v-for="btn in buttons"
|
||||
:key="btn.key"
|
||||
:href="btn.href"
|
||||
target="_blank"
|
||||
size="lg"
|
||||
:class="customClass"
|
||||
:aria-label="btn.ariaLabel"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<img
|
||||
v-if="iconSrc"
|
||||
:src="iconSrc"
|
||||
:src="btn.icon"
|
||||
alt=""
|
||||
class="ppformula-text-center size-5 -translate-y-0.75"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
|
||||
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
|
||||
<p
|
||||
@@ -88,18 +88,18 @@ function getCardClass(layoutClass: string): string {
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
|
||||
class="text-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('cloud.aiModels.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
|
||||
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('cloud.aiModels.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-24 w-full">
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
@@ -180,14 +180,15 @@ function getCardClass(layoutClass: string): string {
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
class="mt-4 w-full max-w-md text-center lg:mt-8 lg:w-auto"
|
||||
size="lg"
|
||||
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
|
||||
>
|
||||
<span class="lg:hidden">{{
|
||||
t('cloud.aiModels.ctaMobile', locale)
|
||||
}}</span>
|
||||
<span class="hidden lg:inline">{{
|
||||
<!-- <span class="lg:hidden"> -->
|
||||
{{ t('cloud.aiModels.ctaMobile', locale) }}
|
||||
<!-- </span> -->
|
||||
<!-- <span class="hidden lg:inline">{{
|
||||
t('cloud.aiModels.ctaDesktop', locale)
|
||||
}}</span>
|
||||
}}</span> -->
|
||||
</BrandButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { externalLinks } from '@/config/routes'
|
||||
|
||||
const downloadUrls = {
|
||||
export const downloadUrls = {
|
||||
windows: 'https://download.comfy.org/windows/nsis/x64',
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
@@ -24,6 +24,8 @@ function detectPlatform(ua: string): DetectedPlatform {
|
||||
// When Linux and/or macIntel builds are added, extend detection and URLs here.
|
||||
export function useDownloadUrl() {
|
||||
const platform = ref<DetectedPlatform>(null)
|
||||
const detected = ref(false)
|
||||
const isMobileUa = ref(false)
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
if (platform.value === 'windows') return downloadUrls.windows
|
||||
@@ -31,9 +33,16 @@ export function useDownloadUrl() {
|
||||
return externalLinks.github
|
||||
})
|
||||
|
||||
const showFallback = computed(
|
||||
() => detected.value && !platform.value && !isMobileUa.value
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
platform.value = detectPlatform(navigator.userAgent.toLowerCase())
|
||||
const ua = navigator.userAgent.toLowerCase()
|
||||
isMobileUa.value = isMobile(ua)
|
||||
platform.value = detectPlatform(ua)
|
||||
detected.value = true
|
||||
})
|
||||
|
||||
return { downloadUrl, platform }
|
||||
return { downloadUrl, platform, showFallback }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
|
||||
hubSlug: 'seedance',
|
||||
featured: true
|
||||
},
|
||||
'grok-image': {
|
||||
'grok-imagine': {
|
||||
hubSlug: 'grok',
|
||||
featured: false
|
||||
},
|
||||
|
||||
44
apps/website/src/data/affiliateAudience.ts
Normal file
44
apps/website/src/data/affiliateAudience.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface AudienceCriterion {
|
||||
id: string
|
||||
label: LocalizedText
|
||||
}
|
||||
|
||||
export const affiliateAudienceCriteria: readonly AudienceCriterion[] = [
|
||||
{
|
||||
id: 'tutorial-creator',
|
||||
label: {
|
||||
en: 'A ComfyUI tutorial creator or workflow builder',
|
||||
'zh-CN': 'ComfyUI 教程作者或工作流创建者'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'ai-tool-reviewer',
|
||||
label: {
|
||||
en: 'An AI tool reviewer on YouTube, TikTok, blogs',
|
||||
'zh-CN': '在 YouTube、TikTok、博客上做 AI 工具测评'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tech-blogger',
|
||||
label: {
|
||||
en: 'A tech blogger covering AI creative tools',
|
||||
'zh-CN': '报道 AI 创作工具的科技博主'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'newsletter-operator',
|
||||
label: {
|
||||
en: 'A newsletter operator in the AI/creative space',
|
||||
'zh-CN': 'AI/创意领域的简报运营者'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'audience-owner',
|
||||
label: {
|
||||
en: 'Anyone with an audience interested in AI image, video, or 3D',
|
||||
'zh-CN': '拥有关注 AI 图像、视频或 3D 受众的任何人'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
39
apps/website/src/data/affiliateBenefits.ts
Normal file
39
apps/website/src/data/affiliateBenefits.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface AffiliateBenefit {
|
||||
id: string
|
||||
description: LocalizedText
|
||||
}
|
||||
|
||||
export const affiliateBenefits: readonly AffiliateBenefit[] = [
|
||||
{
|
||||
id: 'open-source-platform',
|
||||
description: {
|
||||
en: 'ComfyUI is the most powerful open-source AI creative platform',
|
||||
'zh-CN': 'ComfyUI 是最强大的开源 AI 创作平台'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'cloud-no-gpu',
|
||||
description: {
|
||||
en: 'Comfy Cloud lets you run ComfyUI in the browser, no GPU needed, all models pre-loaded',
|
||||
'zh-CN':
|
||||
'Comfy Cloud 让你在浏览器中运行 ComfyUI,无需 GPU,所有模型预加载'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'node-based-control',
|
||||
description: {
|
||||
en: 'Node-based workflows give users full creative control unlike prompt-only tools',
|
||||
'zh-CN':
|
||||
'基于节点的工作流让用户拥有完整的创作控制力,区别于仅靠提示词的工具'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'custom-nodes',
|
||||
description: {
|
||||
en: '1,000+ community custom node packages',
|
||||
'zh-CN': '1,000+ 社区自定义节点包'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
38
apps/website/src/data/affiliateBrandAssets.ts
Normal file
38
apps/website/src/data/affiliateBrandAssets.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface AffiliateBrandAsset {
|
||||
id: string
|
||||
title: LocalizedText
|
||||
download: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
|
||||
{
|
||||
id: 'core-logo',
|
||||
title: { en: 'Core Logo', 'zh-CN': '核心标志' },
|
||||
download: BRAND_ASSETS_ZIP,
|
||||
preview: '/icons/logo.svg'
|
||||
},
|
||||
{
|
||||
id: 'logomark',
|
||||
title: { en: 'Logomark', 'zh-CN': '标志符号' },
|
||||
download: BRAND_ASSETS_ZIP,
|
||||
preview: '/icons/logomark.svg'
|
||||
},
|
||||
{
|
||||
id: 'icon',
|
||||
title: { en: 'Icon', 'zh-CN': '图标' },
|
||||
download: BRAND_ASSETS_ZIP,
|
||||
preview: '/affiliates/brand/comfy-color-combo-yellow.svg'
|
||||
},
|
||||
{
|
||||
id: 'amplified-logomark',
|
||||
title: { en: 'Amplified Logomark', 'zh-CN': '放大版标志符号' },
|
||||
download: BRAND_ASSETS_ZIP,
|
||||
preview: '/affiliates/brand/comfy-amplified-logo.png'
|
||||
}
|
||||
] as const
|
||||
103
apps/website/src/data/affiliateFaq.ts
Normal file
103
apps/website/src/data/affiliateFaq.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface AffiliateFaq {
|
||||
id: string
|
||||
question: LocalizedText
|
||||
answer: LocalizedText
|
||||
}
|
||||
|
||||
export const affiliateFaqs: readonly AffiliateFaq[] = [
|
||||
{
|
||||
id: 'how-do-i-track-my-referrals',
|
||||
question: {
|
||||
en: 'How do I track my referrals?',
|
||||
'zh-CN': '我如何追踪我的推荐?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Real-time dashboard via our partner portal.',
|
||||
'zh-CN': '通过我们的合作伙伴门户使用实时仪表盘追踪。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'what-plans-qualify',
|
||||
question: {
|
||||
en: 'What plans qualify?',
|
||||
'zh-CN': '哪些订阅方案符合条件?'
|
||||
},
|
||||
answer: {
|
||||
en: 'All Comfy Cloud paid subscription plans (Standard, Creator, Pro, Teams).',
|
||||
'zh-CN':
|
||||
'所有 Comfy Cloud 付费订阅方案(Standard、Creator、Pro、Teams)。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'how-long-to-get-approved',
|
||||
question: {
|
||||
en: 'How long does approval take?',
|
||||
'zh-CN': '审核需要多长时间?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Most applications approved within 24 hours.',
|
||||
'zh-CN': '大多数申请会在 24 小时内获批。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'when-do-i-get-paid',
|
||||
question: {
|
||||
en: 'When do I get paid?',
|
||||
'zh-CN': '什么时候结算佣金?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
|
||||
'zh-CN':
|
||||
'每月结算,于每月前 10 个工作日内发放。最低结算余额为 100 美元,通过 Stripe Express 或 PayPal 支付。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'what-happens-if-referral-upgrades-or-downgrades',
|
||||
question: {
|
||||
en: 'What happens if my referral upgrades or downgrades?',
|
||||
'zh-CN': '如果我推荐的用户升级或降级订阅会怎样?'
|
||||
},
|
||||
answer: {
|
||||
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
|
||||
'zh-CN':
|
||||
'如果他们升级订阅,您的佣金会相应增加;如果降级,佣金也会同步调整。佣金以 Comfy.org 实际收到的金额为准,并扣除退款部分。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'can-i-use-coupon-codes',
|
||||
question: {
|
||||
en: 'Can I use coupon codes?',
|
||||
'zh-CN': '我可以使用优惠码吗?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Yes. We support both tracking links and unique coupon codes.',
|
||||
'zh-CN': '可以。我们同时支持追踪链接和专属优惠码。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'what-if-my-referral-uses-an-ad-blocker',
|
||||
question: {
|
||||
en: 'What if my referral uses an ad blocker?',
|
||||
'zh-CN': '如果我推荐的用户使用广告拦截器怎么办?'
|
||||
},
|
||||
answer: {
|
||||
en: 'We use server-side tracking, so conversions are tracked regardless.',
|
||||
'zh-CN':
|
||||
'我们采用服务端追踪,因此无论用户是否使用广告拦截器,转化都能正常记录。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'what-assets-do-you-provide',
|
||||
question: {
|
||||
en: 'What assets do you provide?',
|
||||
'zh-CN': '你们提供哪些素材?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'本页面提供 Logo 和横幅图,获批后您还可以在联盟仪表盘中获取截图和宣传文案。'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
45
apps/website/src/data/affiliateHowItWorks.ts
Normal file
45
apps/website/src/data/affiliateHowItWorks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface HowItWorksStep {
|
||||
id: string
|
||||
label: LocalizedText
|
||||
description: LocalizedText
|
||||
}
|
||||
|
||||
export const affiliateHowItWorksSteps: readonly HowItWorksStep[] = [
|
||||
{
|
||||
id: 'apply',
|
||||
label: {
|
||||
en: 'Apply',
|
||||
'zh-CN': '申请'
|
||||
},
|
||||
description: {
|
||||
en: 'Submit a quick form. Most applicants approved same day.',
|
||||
'zh-CN': '填写一份简短表单。大多数申请当天获批。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'share',
|
||||
label: {
|
||||
en: 'Share',
|
||||
'zh-CN': '分享'
|
||||
},
|
||||
description: {
|
||||
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
|
||||
'zh-CN':
|
||||
'获取您的专属追踪链接。通过内容、社交、邮件等任何触达受众的方式分享。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'earn',
|
||||
label: {
|
||||
en: 'Earn',
|
||||
'zh-CN': '赚取'
|
||||
},
|
||||
description: {
|
||||
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
|
||||
'zh-CN':
|
||||
'每位您推荐的 Comfy Cloud 订阅者,可获连续 3 个月 30% 的经常性佣金。实时追踪,每月结算。'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
@@ -7,6 +7,8 @@ export interface GalleryItem {
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
objectPosition?: string
|
||||
objectFit?: string
|
||||
/** Defaults to true. Set to false to hide this item from rendered lists. */
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
@@ -1879,8 +1879,9 @@ const translations = {
|
||||
'如果我们的网站包含指向第三方网站和服务的链接,请注意这些网站和服务有自己的隐私政策。在访问任何第三方内容的链接后,您应阅读其发布的关于如何收集和使用个人信息的隐私政策信息。本隐私政策不适用于您离开我们网站后的任何活动。'
|
||||
},
|
||||
'privacy.intro.block.3': {
|
||||
en: 'This policy is effective as of April 18, 2025.',
|
||||
'zh-CN': '本政策自 2025 年 4 月 18 日起生效。'
|
||||
en: 'This policy is effective as of April 18, 2025. For information specific to Comfy Desktop (the local install application), including named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights, see our <a href="/privacy/desktop" class="text-white underline">Desktop Privacy Policy</a>.',
|
||||
'zh-CN':
|
||||
'本政策自 2025 年 4 月 18 日起生效。有关 Comfy Desktop(本地安装应用程序)的具体信息,包括指定的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限以及您的权利,请参阅我们的<a href="/zh-CN/privacy/desktop" class="text-white underline">Desktop 隐私政策</a>。'
|
||||
},
|
||||
'privacy.information-we-collect.label': {
|
||||
en: 'INFORMATION',
|
||||
@@ -2130,6 +2131,181 @@ const translations = {
|
||||
'<a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>'
|
||||
},
|
||||
|
||||
// ── Desktop Privacy Policy ────────────────────────────────────────
|
||||
'desktop_privacy.intro.label': { en: 'OVERVIEW', 'zh-CN': 'OVERVIEW' },
|
||||
'desktop_privacy.intro.block.0': {
|
||||
en: 'Effective 3 June 2026. Applies to the Comfy Desktop application.',
|
||||
'zh-CN': 'Effective 3 June 2026. Applies to the Comfy Desktop application.'
|
||||
},
|
||||
'desktop_privacy.intro.block.1': {
|
||||
en: 'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.',
|
||||
'zh-CN':
|
||||
'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.'
|
||||
},
|
||||
|
||||
'desktop_privacy.controller.label': {
|
||||
en: 'CONTROLLER',
|
||||
'zh-CN': 'CONTROLLER'
|
||||
},
|
||||
'desktop_privacy.controller.title': {
|
||||
en: 'Controller',
|
||||
'zh-CN': 'Controller'
|
||||
},
|
||||
'desktop_privacy.controller.block.0': {
|
||||
en: 'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
'desktop_privacy.data.label': {
|
||||
en: 'DATA WE PROCESS',
|
||||
'zh-CN': 'DATA WE PROCESS'
|
||||
},
|
||||
'desktop_privacy.data.title': {
|
||||
en: 'Personal data we process',
|
||||
'zh-CN': 'Personal data we process'
|
||||
},
|
||||
'desktop_privacy.data.block.0': {
|
||||
en: 'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:',
|
||||
'zh-CN':
|
||||
'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:'
|
||||
},
|
||||
'desktop_privacy.data.block.1': {
|
||||
en: 'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.',
|
||||
'zh-CN':
|
||||
'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.'
|
||||
},
|
||||
'desktop_privacy.data.block.2': {
|
||||
en: 'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).',
|
||||
'zh-CN':
|
||||
'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).'
|
||||
},
|
||||
'desktop_privacy.data.block.3': {
|
||||
en: 'We do not process:',
|
||||
'zh-CN': 'We do not process:'
|
||||
},
|
||||
'desktop_privacy.data.block.4': {
|
||||
en: 'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application',
|
||||
'zh-CN':
|
||||
'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application'
|
||||
},
|
||||
'desktop_privacy.data.block.5': {
|
||||
en: 'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.',
|
||||
'zh-CN':
|
||||
'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.'
|
||||
},
|
||||
|
||||
'desktop_privacy.purposes.label': { en: 'PURPOSES', 'zh-CN': 'PURPOSES' },
|
||||
'desktop_privacy.purposes.title': {
|
||||
en: 'Purposes and lawful bases',
|
||||
'zh-CN': 'Purposes and lawful bases'
|
||||
},
|
||||
'desktop_privacy.purposes.block.0': {
|
||||
en: 'We process personal data on the following lawful bases under GDPR and UK GDPR:',
|
||||
'zh-CN':
|
||||
'We process personal data on the following lawful bases under GDPR and UK GDPR:'
|
||||
},
|
||||
'desktop_privacy.purposes.block.1': {
|
||||
en: 'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).',
|
||||
'zh-CN':
|
||||
'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).'
|
||||
},
|
||||
'desktop_privacy.purposes.block.2': {
|
||||
en: 'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
'desktop_privacy.purposes.block.3': {
|
||||
en: 'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.'
|
||||
},
|
||||
|
||||
'desktop_privacy.processors.label': {
|
||||
en: 'RECIPIENTS',
|
||||
'zh-CN': 'RECIPIENTS'
|
||||
},
|
||||
'desktop_privacy.processors.title': {
|
||||
en: 'Recipients',
|
||||
'zh-CN': 'Recipients'
|
||||
},
|
||||
'desktop_privacy.processors.block.0': {
|
||||
en: 'We engage the following processors under Data Processing Agreements:',
|
||||
'zh-CN':
|
||||
'We engage the following processors under Data Processing Agreements:'
|
||||
},
|
||||
'desktop_privacy.processors.block.1': {
|
||||
en: 'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)',
|
||||
'zh-CN':
|
||||
'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)'
|
||||
},
|
||||
|
||||
'desktop_privacy.transfers.label': { en: 'TRANSFERS', 'zh-CN': 'TRANSFERS' },
|
||||
'desktop_privacy.transfers.title': {
|
||||
en: 'International transfers',
|
||||
'zh-CN': 'International transfers'
|
||||
},
|
||||
'desktop_privacy.transfers.block.0': {
|
||||
en: 'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.'
|
||||
},
|
||||
|
||||
'desktop_privacy.retention.label': { en: 'RETENTION', 'zh-CN': 'RETENTION' },
|
||||
'desktop_privacy.retention.title': { en: 'Retention', 'zh-CN': 'Retention' },
|
||||
'desktop_privacy.retention.block.0': {
|
||||
en: 'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.',
|
||||
'zh-CN':
|
||||
'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.'
|
||||
},
|
||||
|
||||
'desktop_privacy.rights.label': { en: 'YOUR RIGHTS', 'zh-CN': 'YOUR RIGHTS' },
|
||||
'desktop_privacy.rights.title': { en: 'Your rights', 'zh-CN': 'Your rights' },
|
||||
'desktop_privacy.rights.block.0': {
|
||||
en: 'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.',
|
||||
'zh-CN':
|
||||
'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.'
|
||||
},
|
||||
'desktop_privacy.rights.block.1': {
|
||||
en: 'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.'
|
||||
},
|
||||
'desktop_privacy.rights.block.2': {
|
||||
en: "You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency.",
|
||||
'zh-CN':
|
||||
"You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency."
|
||||
},
|
||||
'desktop_privacy.rights.block.3': {
|
||||
en: 'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.',
|
||||
'zh-CN':
|
||||
'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.'
|
||||
},
|
||||
|
||||
'desktop_privacy.children.label': { en: 'CHILDREN', 'zh-CN': 'CHILDREN' },
|
||||
'desktop_privacy.children.title': { en: 'Children', 'zh-CN': 'Children' },
|
||||
'desktop_privacy.children.block.0': {
|
||||
en: 'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.',
|
||||
'zh-CN':
|
||||
'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.'
|
||||
},
|
||||
|
||||
'desktop_privacy.changes.label': { en: 'CHANGES', 'zh-CN': 'CHANGES' },
|
||||
'desktop_privacy.changes.title': { en: 'Changes', 'zh-CN': 'Changes' },
|
||||
'desktop_privacy.changes.block.0': {
|
||||
en: 'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.',
|
||||
'zh-CN':
|
||||
'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.'
|
||||
},
|
||||
|
||||
'desktop_privacy.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
|
||||
'desktop_privacy.contact.title': { en: 'Contact', 'zh-CN': 'Contact' },
|
||||
'desktop_privacy.contact.block.0': {
|
||||
en: 'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
// ── Terms of Service ──────────────────────────────────────────────
|
||||
'tos.effectiveDateLabel': {
|
||||
en: 'Effective Date',
|
||||
@@ -4458,6 +4634,80 @@ const translations = {
|
||||
'zh-CN': '支持的模型'
|
||||
},
|
||||
|
||||
// Models list page (/models)
|
||||
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
|
||||
'models.list.heroCta': {
|
||||
en: 'Try {name} Now',
|
||||
'zh-CN': '立即试用 {name}'
|
||||
},
|
||||
'models.list.creations.title': {
|
||||
en: '{name} Image and Video Creations',
|
||||
'zh-CN': '{name} 图像与视频创作'
|
||||
},
|
||||
'models.list.creations.cta': {
|
||||
en: 'Explore Workflows',
|
||||
'zh-CN': '探索工作流'
|
||||
},
|
||||
'models.list.heroTitle.before': {
|
||||
en: '{name} in',
|
||||
'zh-CN': ''
|
||||
},
|
||||
'models.list.heroTitle.after': {
|
||||
en: '',
|
||||
'zh-CN': ' 中的 {name}'
|
||||
},
|
||||
'models.list.heroSubtitle': {
|
||||
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
|
||||
'zh-CN':
|
||||
'从开源扩散模型到合作伙伴 API,涵盖每一个主流模型,并附带可直接运行的社区工作流模板。'
|
||||
},
|
||||
'models.list.card.workflows': {
|
||||
en: '{count} workflows',
|
||||
'zh-CN': '{count} 个工作流'
|
||||
},
|
||||
'models.list.contact.label': {
|
||||
en: 'COMFY HUB',
|
||||
'zh-CN': 'COMFY HUB'
|
||||
},
|
||||
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
|
||||
'models.showcase.heading': {
|
||||
en: 'Run the world’s\nleading AI models',
|
||||
'zh-CN': '运行全球领先的\nAI 模型'
|
||||
},
|
||||
'models.showcase.subtitle': {
|
||||
en: 'New models are added as they launch.',
|
||||
'zh-CN': '新模型发布后会第一时间上线。'
|
||||
},
|
||||
'models.showcase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
'zh-CN': '探索工作流'
|
||||
},
|
||||
'models.showcase.card.grokImagine': {
|
||||
en: 'Grok Imagine',
|
||||
'zh-CN': 'Grok Imagine'
|
||||
},
|
||||
'models.showcase.card.nanoBananaPro': {
|
||||
en: 'Nano Banana Pro',
|
||||
'zh-CN': 'Nano Banana Pro'
|
||||
},
|
||||
'models.showcase.card.ltx23': {
|
||||
en: 'LTX 2.3',
|
||||
'zh-CN': 'LTX 2.3'
|
||||
},
|
||||
'models.showcase.card.qwenAdvancedEdit': {
|
||||
en: 'Advanced image\nediting with Qwen',
|
||||
'zh-CN': '使用 Qwen 进行\n高级图像编辑'
|
||||
},
|
||||
'models.showcase.card.wan22TextToVideo': {
|
||||
en: 'Wan 2.2\ntext to video',
|
||||
'zh-CN': 'Wan 2.2\n文字转视频'
|
||||
},
|
||||
'models.list.contact.heading': {
|
||||
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
|
||||
'zh-CN':
|
||||
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
'payment.success.label': {
|
||||
en: 'PAYMENT',
|
||||
@@ -4501,313 +4751,103 @@ const translations = {
|
||||
'zh-CN': '查看订阅文档'
|
||||
},
|
||||
|
||||
// Affiliate landing page (/affiliates)
|
||||
// English-only copy; zh-CN values mirror en until a localized version lands.
|
||||
'affiliate-landing.page.title': {
|
||||
en: 'Comfy.org Affiliate Program — Become a Partner',
|
||||
'zh-CN': 'Comfy.org Affiliate Program — Become a Partner'
|
||||
// AffiliateHeroSection
|
||||
'affiliate.hero.label': { en: 'AFFILIATE', 'zh-CN': '联盟' },
|
||||
'affiliate.hero.headingHighlight': {
|
||||
en: 'Earn 30%',
|
||||
'zh-CN': '赚取 30%'
|
||||
},
|
||||
'affiliate-landing.page.description': {
|
||||
'affiliate.hero.headingMuted': {
|
||||
en: 'recurring commission for 3 months.',
|
||||
'zh-CN': '持续返佣 3 个月。'
|
||||
},
|
||||
'affiliate.hero.feature1': {
|
||||
en: '30% recurring commission for 3 months',
|
||||
'zh-CN': '30% 持续佣金,连续 3 个月'
|
||||
},
|
||||
'affiliate.hero.feature2': {
|
||||
en: '60-day cookie window',
|
||||
'zh-CN': '60 天 Cookie 窗口'
|
||||
},
|
||||
'affiliate.hero.feature3': {
|
||||
en: '$100 minimum payout',
|
||||
'zh-CN': '$100 起付'
|
||||
},
|
||||
'affiliate.hero.feature4': {
|
||||
en: 'Monthly payouts',
|
||||
'zh-CN': '每月结算'
|
||||
},
|
||||
'affiliate.hero.apply': { en: 'APPLY NOW', 'zh-CN': '立即申请' },
|
||||
'affiliate.hero.imageAlt': {
|
||||
en: 'Comfy affiliate program',
|
||||
'zh-CN': 'Comfy 联盟计划'
|
||||
},
|
||||
|
||||
// AffiliateAudienceSection
|
||||
'affiliate.audience.heading': {
|
||||
en: "Who we're looking for",
|
||||
'zh-CN': '我们在寻找谁'
|
||||
},
|
||||
'affiliate.audience.subheading': {
|
||||
en: 'If you are...',
|
||||
'zh-CN': '如果您是……'
|
||||
},
|
||||
|
||||
// AffiliateHowItWorksSection
|
||||
'affiliate.howItWorks.heading': {
|
||||
en: 'How it works',
|
||||
'zh-CN': '运作方式'
|
||||
},
|
||||
|
||||
// AffiliateBenefitsSection
|
||||
'affiliate.benefits.heading': {
|
||||
en: 'Why ComfyUI for affiliate creators',
|
||||
'zh-CN': '为什么联盟创作者选择 ComfyUI'
|
||||
},
|
||||
|
||||
// AffiliateBrandAssetsSection
|
||||
'affiliate.assets.heading': {
|
||||
en: 'Brand logos for your content',
|
||||
'zh-CN': '可用于您内容的品牌 Logo'
|
||||
},
|
||||
'affiliate.assets.subheading': {
|
||||
en: 'Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
|
||||
'zh-CN': '横幅图、截图和宣传文案将在获批后于联盟仪表盘中提供。'
|
||||
},
|
||||
'affiliate.assets.downloadLabel': {
|
||||
en: 'Download zip',
|
||||
'zh-CN': '下载压缩包'
|
||||
},
|
||||
|
||||
// AffiliateFAQSection
|
||||
'affiliate.faq.heading': {
|
||||
en: 'Frequently asked questions',
|
||||
'zh-CN': '常见问题'
|
||||
},
|
||||
|
||||
// Affiliate page (/affiliates) — head metadata
|
||||
'affiliate.page.title': {
|
||||
en: 'Comfy.org Affiliate Program — Become a Partner',
|
||||
'zh-CN': 'Comfy.org 联盟计划 — 成为合作伙伴'
|
||||
},
|
||||
'affiliate.page.description': {
|
||||
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
|
||||
'zh-CN':
|
||||
'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.'
|
||||
},
|
||||
'affiliate-landing.cta.apply': {
|
||||
en: 'Apply Now',
|
||||
'zh-CN': 'Apply Now'
|
||||
},
|
||||
'affiliate-landing.cta.applyAriaLabel': {
|
||||
en: 'Apply Now (opens in new tab)',
|
||||
'zh-CN': 'Apply Now (opens in new tab)'
|
||||
'为您推荐的每个 Comfy Cloud 订阅赚取 30% 持续佣金,连续 3 个月。立即申请成为 Comfy 合作伙伴。'
|
||||
},
|
||||
|
||||
// Hero
|
||||
'affiliate-landing.hero.heading': {
|
||||
en: 'Become a Comfy Partner',
|
||||
'zh-CN': 'Become a Comfy Partner'
|
||||
},
|
||||
'affiliate-landing.hero.subheading': {
|
||||
en: 'Earn 30% Commission for 3 Months.',
|
||||
'zh-CN': 'Earn 30% Commission for 3 Months.'
|
||||
},
|
||||
'affiliate-landing.hero.body': {
|
||||
en: 'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:',
|
||||
'zh-CN':
|
||||
'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.0': {
|
||||
en: '30% recurring commission for 3 months',
|
||||
'zh-CN': '30% recurring commission for 3 months'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.1': {
|
||||
en: '60-day cookie window',
|
||||
'zh-CN': '60-day cookie window'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.2': {
|
||||
en: '$100 minimum payout',
|
||||
'zh-CN': '$100 minimum payout'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.3': {
|
||||
en: 'Monthly payouts',
|
||||
'zh-CN': 'Monthly payouts'
|
||||
},
|
||||
|
||||
// Trust band
|
||||
'affiliate-landing.trust.label': {
|
||||
en: 'Trusted by 2M+ creators worldwide',
|
||||
'zh-CN': 'Trusted by 2M+ creators worldwide'
|
||||
},
|
||||
|
||||
// How it works
|
||||
'affiliate-landing.how.heading': {
|
||||
en: 'How it works',
|
||||
'zh-CN': 'How it works'
|
||||
},
|
||||
'affiliate-landing.how.step.0.title': {
|
||||
en: 'Apply.',
|
||||
'zh-CN': 'Apply.'
|
||||
},
|
||||
'affiliate-landing.how.step.0.body': {
|
||||
en: 'Submit a quick form. Most applicants approved same day.',
|
||||
'zh-CN': 'Submit a quick form. Most applicants approved same day.'
|
||||
},
|
||||
'affiliate-landing.how.step.1.title': {
|
||||
en: 'Share.',
|
||||
'zh-CN': 'Share.'
|
||||
},
|
||||
'affiliate-landing.how.step.1.body': {
|
||||
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
|
||||
'zh-CN':
|
||||
'Get your unique tracking link. Share via content, social, email, however you reach your audience.'
|
||||
},
|
||||
'affiliate-landing.how.step.2.title': {
|
||||
en: 'Earn.',
|
||||
'zh-CN': 'Earn.'
|
||||
},
|
||||
'affiliate-landing.how.step.2.body': {
|
||||
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
|
||||
'zh-CN':
|
||||
'30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.'
|
||||
},
|
||||
|
||||
// Who we're looking for
|
||||
'affiliate-landing.audience.heading': {
|
||||
en: "Who we're looking for",
|
||||
'zh-CN': "Who we're looking for"
|
||||
},
|
||||
'affiliate-landing.audience.item.0': {
|
||||
en: 'ComfyUI tutorial creators and workflow builders',
|
||||
'zh-CN': 'ComfyUI tutorial creators and workflow builders'
|
||||
},
|
||||
'affiliate-landing.audience.item.1': {
|
||||
en: 'AI tool reviewers on YouTube, TikTok, blogs',
|
||||
'zh-CN': 'AI tool reviewers on YouTube, TikTok, blogs'
|
||||
},
|
||||
'affiliate-landing.audience.item.2': {
|
||||
en: 'Tech bloggers covering AI creative tools',
|
||||
'zh-CN': 'Tech bloggers covering AI creative tools'
|
||||
},
|
||||
'affiliate-landing.audience.item.3': {
|
||||
en: 'Newsletter operators in the AI/creative space',
|
||||
'zh-CN': 'Newsletter operators in the AI/creative space'
|
||||
},
|
||||
'affiliate-landing.audience.item.4': {
|
||||
en: 'Anyone with an audience interested in AI image, video, or 3D generation',
|
||||
'zh-CN':
|
||||
'Anyone with an audience interested in AI image, video, or 3D generation'
|
||||
},
|
||||
|
||||
// Program details
|
||||
'affiliate-landing.details.heading': {
|
||||
en: 'Program details',
|
||||
'zh-CN': 'Program details'
|
||||
},
|
||||
'affiliate-landing.details.headerLabel': {
|
||||
en: 'Detail',
|
||||
'zh-CN': 'Detail'
|
||||
},
|
||||
'affiliate-landing.details.headerValue': {
|
||||
en: 'Value',
|
||||
'zh-CN': 'Value'
|
||||
},
|
||||
'affiliate-landing.details.row.0.label': {
|
||||
en: 'Commission rate',
|
||||
'zh-CN': 'Commission rate'
|
||||
},
|
||||
'affiliate-landing.details.row.0.value': {
|
||||
en: '30% recurring',
|
||||
'zh-CN': '30% recurring'
|
||||
},
|
||||
'affiliate-landing.details.row.1.label': {
|
||||
en: 'Commission duration',
|
||||
'zh-CN': 'Commission duration'
|
||||
},
|
||||
'affiliate-landing.details.row.1.value': {
|
||||
en: '3 months',
|
||||
'zh-CN': '3 months'
|
||||
},
|
||||
'affiliate-landing.details.row.2.label': {
|
||||
en: 'Cookie window',
|
||||
'zh-CN': 'Cookie window'
|
||||
},
|
||||
'affiliate-landing.details.row.2.value': {
|
||||
en: '60 days',
|
||||
'zh-CN': '60 days'
|
||||
},
|
||||
'affiliate-landing.details.row.3.label': {
|
||||
en: 'Eligible products',
|
||||
'zh-CN': 'Eligible products'
|
||||
},
|
||||
'affiliate-landing.details.row.3.value': {
|
||||
en: 'Comfy Cloud paid subscription plans',
|
||||
'zh-CN': 'Comfy Cloud paid subscription plans'
|
||||
},
|
||||
'affiliate-landing.details.row.4.label': {
|
||||
en: 'Payouts',
|
||||
'zh-CN': 'Payouts'
|
||||
},
|
||||
'affiliate-landing.details.row.4.value': {
|
||||
en: 'Monthly, within first 10 business days',
|
||||
'zh-CN': 'Monthly, within first 10 business days'
|
||||
},
|
||||
'affiliate-landing.details.row.5.label': {
|
||||
en: 'Minimum payout',
|
||||
'zh-CN': 'Minimum payout'
|
||||
},
|
||||
'affiliate-landing.details.row.5.value': {
|
||||
en: '$100',
|
||||
'zh-CN': '$100'
|
||||
},
|
||||
|
||||
// Brand assets
|
||||
'affiliate-landing.assets.heading': {
|
||||
en: 'Brand assets',
|
||||
'zh-CN': 'Brand assets'
|
||||
},
|
||||
'affiliate-landing.assets.subheading': {
|
||||
en: 'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.'
|
||||
},
|
||||
'affiliate-landing.assets.downloadLabel': {
|
||||
en: 'Download',
|
||||
'zh-CN': 'Download'
|
||||
},
|
||||
'affiliate-landing.assets.tile.logo-horizontal.title': {
|
||||
en: 'Comfy logo (horizontal)',
|
||||
'zh-CN': 'Comfy logo (horizontal)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.logomark.title': {
|
||||
en: 'Comfy logomark',
|
||||
'zh-CN': 'Comfy logomark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.comfy-full-logo-yellow.title': {
|
||||
en: 'Comfy full logo (yellow)',
|
||||
'zh-CN': 'Comfy full logo (yellow)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.comfy-full-logo-ink.title': {
|
||||
en: 'Comfy full logo (ink)',
|
||||
'zh-CN': 'Comfy full logo (ink)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.amplified-logo-mark.title': {
|
||||
en: 'Amplified logo mark',
|
||||
'zh-CN': 'Amplified logo mark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.dimensional-logo-mark.title': {
|
||||
en: 'Dimensional logo mark',
|
||||
'zh-CN': 'Dimensional logo mark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.color-combo-yellow.title': {
|
||||
en: 'Color combo (yellow)',
|
||||
'zh-CN': 'Color combo (yellow)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.color-combo-ink.title': {
|
||||
en: 'Color combo (ink)',
|
||||
'zh-CN': 'Color combo (ink)'
|
||||
},
|
||||
|
||||
// FAQ — keys follow the FAQSection contract: <prefix>.<n>.q / <prefix>.<n>.a, 1-indexed
|
||||
'affiliate-landing.faq.heading': {
|
||||
en: 'Questions',
|
||||
'zh-CN': 'Questions'
|
||||
},
|
||||
'affiliate-landing.faq.1.q': {
|
||||
en: 'How do I track my referrals?',
|
||||
'zh-CN': 'How do I track my referrals?'
|
||||
},
|
||||
'affiliate-landing.faq.1.a': {
|
||||
en: 'Real-time dashboard via our partner portal.',
|
||||
'zh-CN': 'Real-time dashboard via our partner portal.'
|
||||
},
|
||||
'affiliate-landing.faq.2.q': {
|
||||
en: 'What plans qualify?',
|
||||
'zh-CN': 'What plans qualify?'
|
||||
},
|
||||
'affiliate-landing.faq.2.a': {
|
||||
en: 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).',
|
||||
'zh-CN': 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).'
|
||||
},
|
||||
'affiliate-landing.faq.3.q': {
|
||||
en: 'How long does approval take?',
|
||||
'zh-CN': 'How long does approval take?'
|
||||
},
|
||||
'affiliate-landing.faq.3.a': {
|
||||
en: 'Most applications approved within 24 hours.',
|
||||
'zh-CN': 'Most applications approved within 24 hours.'
|
||||
},
|
||||
'affiliate-landing.faq.4.q': {
|
||||
en: 'When do I get paid?',
|
||||
'zh-CN': 'When do I get paid?'
|
||||
},
|
||||
'affiliate-landing.faq.4.a': {
|
||||
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
|
||||
'zh-CN':
|
||||
'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.'
|
||||
},
|
||||
'affiliate-landing.faq.5.q': {
|
||||
en: 'What happens if my referral upgrades or downgrades?',
|
||||
'zh-CN': 'What happens if my referral upgrades or downgrades?'
|
||||
},
|
||||
'affiliate-landing.faq.5.a': {
|
||||
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
|
||||
'zh-CN':
|
||||
'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.'
|
||||
},
|
||||
'affiliate-landing.faq.6.q': {
|
||||
en: 'Can I use coupon codes?',
|
||||
'zh-CN': 'Can I use coupon codes?'
|
||||
},
|
||||
'affiliate-landing.faq.6.a': {
|
||||
en: 'Yes. We support both tracking links and unique coupon codes.',
|
||||
'zh-CN': 'Yes. We support both tracking links and unique coupon codes.'
|
||||
},
|
||||
'affiliate-landing.faq.7.q': {
|
||||
en: 'What if my referral uses an ad blocker?',
|
||||
'zh-CN': 'What if my referral uses an ad blocker?'
|
||||
},
|
||||
'affiliate-landing.faq.7.a': {
|
||||
en: 'We use server-side tracking, so conversions are tracked regardless.',
|
||||
'zh-CN':
|
||||
'We use server-side tracking, so conversions are tracked regardless.'
|
||||
},
|
||||
'affiliate-landing.faq.8.q': {
|
||||
en: 'What assets do you provide?',
|
||||
'zh-CN': 'What assets do you provide?'
|
||||
},
|
||||
'affiliate-landing.faq.8.a': {
|
||||
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.'
|
||||
},
|
||||
|
||||
// Footer CTA
|
||||
'affiliate-landing.footerCta.heading': {
|
||||
// AffiliateCtaSection
|
||||
'affiliate.cta.heading': {
|
||||
en: 'Ready to start earning?',
|
||||
'zh-CN': 'Ready to start earning?'
|
||||
'zh-CN': '准备好开始赚取佣金了吗?'
|
||||
},
|
||||
'affiliate-landing.footerCta.termsLink': {
|
||||
'affiliate.cta.apply': {
|
||||
en: 'APPLY NOW',
|
||||
'zh-CN': '立即申请'
|
||||
},
|
||||
'affiliate.cta.termsLabel': {
|
||||
en: 'Read the affiliate program terms',
|
||||
'zh-CN': 'Read the affiliate program terms'
|
||||
'zh-CN': '阅读联盟计划条款'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
@@ -1,44 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import AudienceSection from '../../components/affiliates/AudienceSection.vue'
|
||||
import BrandAssetsSection from '../../components/affiliates/BrandAssetsSection.vue'
|
||||
import FooterCtaSection from '../../components/affiliates/FooterCtaSection.vue'
|
||||
import HeroSection from '../../components/affiliates/HeroSection.vue'
|
||||
import HowItWorksSection from '../../components/affiliates/HowItWorksSection.vue'
|
||||
import ProgramDetailsSection from '../../components/affiliates/ProgramDetailsSection.vue'
|
||||
import TrustBandSection from '../../components/affiliates/TrustBandSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_HEADING_KEY,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from '../../components/affiliates/affiliateFaqs'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import AudienceSection from '../../templates/affiliate/AudienceSection.vue'
|
||||
import BenefitsSection from '../../templates/affiliate/BenefitsSection.vue'
|
||||
import BrandAssetsSection from '../../templates/affiliate/BrandAssetsSection.vue'
|
||||
import CtaSection from '../../templates/affiliate/CtaSection.vue'
|
||||
import FAQSection from '../../templates/affiliate/FAQSection.vue'
|
||||
import HeroSection from '../../templates/affiliate/HeroSection.vue'
|
||||
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale: Locale =
|
||||
Astro.currentLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const locale = 'en' as const
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => {
|
||||
const n = i + 1
|
||||
return {
|
||||
'@type': 'Question',
|
||||
name: t(`${AFFILIATE_FAQ_PREFIX}.${n}.q` as TranslationKey, locale),
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: t(`${AFFILIATE_FAQ_PREFIX}.${n}.a` as TranslationKey, locale)
|
||||
}
|
||||
mainEntity: affiliateFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer[locale]
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('affiliate-landing.page.title', locale)}
|
||||
description={t('affiliate-landing.page.description', locale)}
|
||||
title={t('affiliate.page.title', locale)}
|
||||
description={t('affiliate.page.description', locale)}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
@@ -48,18 +38,11 @@ const faqJsonLd = {
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<HeroSection locale={locale} client:load />
|
||||
<TrustBandSection locale={locale} />
|
||||
<HowItWorksSection locale={locale} />
|
||||
<AudienceSection locale={locale} />
|
||||
<ProgramDetailsSection locale={locale} />
|
||||
<BrandAssetsSection locale={locale} />
|
||||
<FAQSection
|
||||
locale={locale}
|
||||
headingKey={AFFILIATE_FAQ_HEADING_KEY}
|
||||
faqPrefix={AFFILIATE_FAQ_PREFIX}
|
||||
faqCount={AFFILIATE_FAQ_COUNT}
|
||||
client:load
|
||||
/>
|
||||
<FooterCtaSection locale={locale} client:load />
|
||||
<HeroSection />
|
||||
<HowItWorksSection />
|
||||
<AudienceSection />
|
||||
<BenefitsSection />
|
||||
<BrandAssetsSection />
|
||||
<FAQSection client:visible />
|
||||
<CtaSection />
|
||||
</BaseLayout>
|
||||
|
||||
22
apps/website/src/pages/models.astro
Normal file
22
apps/website/src/pages/models.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
|
||||
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Models — Comfy"
|
||||
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
|
||||
>
|
||||
<ModelsHeroSection
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/p/supported-models/grok-imagine"
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="Grok Imagine output created with ComfyUI"
|
||||
/>
|
||||
<ModelCreationsSection client:load />
|
||||
<AIModelsSection client:load />
|
||||
<ProductShowcaseSection client:load />
|
||||
</BaseLayout>
|
||||
13
apps/website/src/pages/privacy/desktop.astro
Normal file
13
apps/website/src/pages/privacy/desktop.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop Privacy Policy — Comfy"
|
||||
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
|
||||
>
|
||||
<HeroSection title="Desktop Privacy Policy" />
|
||||
<ContentSection prefix="desktop_privacy" client:load />
|
||||
</BaseLayout>
|
||||
23
apps/website/src/pages/zh-CN/models.astro
Normal file
23
apps/website/src/pages/zh-CN/models.astro
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
|
||||
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
|
||||
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="模型 — Comfy"
|
||||
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
|
||||
>
|
||||
<ModelsHeroSection
|
||||
locale="zh-CN"
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/zh-CN/p/supported-models/grok-imagine"
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
|
||||
/>
|
||||
<ModelCreationsSection client:load locale="zh-CN" />
|
||||
<AIModelsSection client:load locale="zh-CN" />
|
||||
<ProductShowcaseSection client:load locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
13
apps/website/src/pages/zh-CN/privacy/desktop.astro
Normal file
13
apps/website/src/pages/zh-CN/privacy/desktop.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop 隐私政策 — Comfy"
|
||||
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
|
||||
>
|
||||
<HeroSection title="Desktop 隐私政策" />
|
||||
<ContentSection prefix="desktop_privacy" locale="zh-CN" client:load />
|
||||
</BaseLayout>
|
||||
22
apps/website/src/templates/affiliate/AudienceSection.vue
Normal file
22
apps/website/src/templates/affiliate/AudienceSection.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import ChecklistSplit01 from '../../components/blocks/ChecklistSplit01.vue'
|
||||
import { affiliateAudienceCriteria } from '../../data/affiliateAudience'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const criteria = affiliateAudienceCriteria.map((criterion) => ({
|
||||
id: criterion.id,
|
||||
label: criterion.label[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChecklistSplit01
|
||||
:heading="t('affiliate.audience.heading', locale)"
|
||||
:subheading="t('affiliate.audience.subheading', locale)"
|
||||
:criteria="criteria"
|
||||
/>
|
||||
</template>
|
||||
27
apps/website/src/templates/affiliate/BenefitsSection.vue
Normal file
27
apps/website/src/templates/affiliate/BenefitsSection.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import BenefitsGrid01 from '../../components/blocks/BenefitsGrid01.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { affiliateBenefits } from '../../data/affiliateBenefits'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const benefits = affiliateBenefits.map((benefit) => ({
|
||||
id: benefit.id,
|
||||
description: benefit.description[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BenefitsGrid01
|
||||
:heading="t('affiliate.benefits.heading', locale)"
|
||||
:benefits="benefits"
|
||||
:primary-cta="{
|
||||
label: t('affiliate.hero.apply', locale),
|
||||
href: externalLinks.affiliateApplicationForm,
|
||||
target: '_blank'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
25
apps/website/src/templates/affiliate/BrandAssetsSection.vue
Normal file
25
apps/website/src/templates/affiliate/BrandAssetsSection.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import BrandAssetsGrid01 from '../../components/blocks/BrandAssetsGrid01.vue'
|
||||
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const assets = affiliateBrandAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title[locale],
|
||||
download: asset.download,
|
||||
preview: asset.preview
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BrandAssetsGrid01
|
||||
:heading="t('affiliate.assets.heading', locale)"
|
||||
:subheading="t('affiliate.assets.subheading', locale)"
|
||||
:download-label="t('affiliate.assets.downloadLabel', locale)"
|
||||
:assets="assets"
|
||||
/>
|
||||
</template>
|
||||
26
apps/website/src/templates/affiliate/CtaSection.vue
Normal file
26
apps/website/src/templates/affiliate/CtaSection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CtaCenter01
|
||||
:heading="t('affiliate.cta.heading', locale)"
|
||||
:primary-cta="{
|
||||
label: t('affiliate.cta.apply', locale),
|
||||
href: externalLinks.affiliateApplicationForm,
|
||||
target: '_blank'
|
||||
}"
|
||||
:terms-link="{
|
||||
label: t('affiliate.cta.termsLabel', locale),
|
||||
href: routes.affiliateTerms
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
19
apps/website/src/templates/affiliate/FAQSection.vue
Normal file
19
apps/website/src/templates/affiliate/FAQSection.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqs = affiliateFaqs.map((faq) => ({
|
||||
id: faq.id,
|
||||
question: faq.question[locale],
|
||||
answer: faq.answer[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FAQSplit01 :heading="t('affiliate.faq.heading', locale)" :faqs="faqs" />
|
||||
</template>
|
||||
32
apps/website/src/templates/affiliate/HeroSection.vue
Normal file
32
apps/website/src/templates/affiliate/HeroSection.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
|
||||
import { externalLinks } from '@/config/routes.ts'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroSplit01
|
||||
:badge-text="t('affiliate.hero.label', locale)"
|
||||
:title-highlight="t('affiliate.hero.headingHighlight', locale)"
|
||||
:title="t('affiliate.hero.headingMuted', locale)"
|
||||
:features="[
|
||||
t('affiliate.hero.feature1', locale),
|
||||
t('affiliate.hero.feature2', locale),
|
||||
t('affiliate.hero.feature3', locale),
|
||||
t('affiliate.hero.feature4', locale)
|
||||
]"
|
||||
:primary-cta="{
|
||||
label: t('affiliate.hero.apply', locale),
|
||||
href: externalLinks.affiliateApplicationForm
|
||||
}"
|
||||
video-autoplay
|
||||
video-loop
|
||||
video-hide-controls
|
||||
video-src="https://media.comfy.org/website/affiliates/rainlit-ronin_compressed.mp4"
|
||||
:image-alt="t('affiliate.hero.imageAlt', locale)"
|
||||
/>
|
||||
</template>
|
||||
22
apps/website/src/templates/affiliate/HowItWorksSection.vue
Normal file
22
apps/website/src/templates/affiliate/HowItWorksSection.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import StepsGrid01 from '../../components/blocks/StepsGrid01.vue'
|
||||
import { affiliateHowItWorksSteps } from '../../data/affiliateHowItWorks'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const steps = affiliateHowItWorksSteps.map((step) => ({
|
||||
id: step.id,
|
||||
label: step.label[locale],
|
||||
description: step.description[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StepsGrid01
|
||||
:heading="t('affiliate.howItWorks.heading', locale)"
|
||||
:steps="steps"
|
||||
/>
|
||||
</template>
|
||||
48
browser_tests/assets/missing/missing_nodes_same_pack.json
Normal file
48
browser_tests/assets/missing/missing_nodes_same_pack.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "TEST_MISSING_PACK_NODE_A",
|
||||
"pos": [48, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "TEST_MISSING_PACK_NODE_B",
|
||||
"pos": [520, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -45,6 +45,8 @@ export const TestIds = {
|
||||
errorOverlayMessages: 'error-overlay-messages',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
missingNodePackExpand: 'missing-node-pack-expand',
|
||||
missingNodePackCount: 'missing-node-pack-count',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
errorDialog: 'error-dialog',
|
||||
@@ -69,11 +71,6 @@ export const TestIds = {
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
missingMediaLibrarySelect: 'missing-media-library-select',
|
||||
missingMediaStatusCard: 'missing-media-status-card',
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
|
||||
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
|
||||
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('Use button ghost-places a loader populated with the model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const card = comfyPage.page.locator(
|
||||
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
|
||||
)
|
||||
await expect(card).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
// Dialog closes and the ghost is armed; the node is not placed until the
|
||||
// user clicks the canvas.
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
|
||||
})
|
||||
})
|
||||
@@ -545,4 +545,54 @@ test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Responsive Layout', () => {
|
||||
test('Action buttons stay on screen without horizontal scroll at narrow widths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 800 })
|
||||
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Add new keybinding/i })
|
||||
).toBeInViewport()
|
||||
|
||||
const hasHorizontalScroll = await page
|
||||
.locator('.keybinding-panel .p-datatable-table-container')
|
||||
.evaluate((el) => el.scrollWidth > el.clientWidth + 1)
|
||||
expect(hasHorizontalScroll).toBe(false)
|
||||
})
|
||||
|
||||
test('Keybinding column compresses with width while actions stay reachable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
const keybindingList = row.getByTestId('keybinding-list')
|
||||
await expect(keybindingList).toBeVisible()
|
||||
|
||||
const listWidthAt = async (viewportWidth: number) => {
|
||||
await page.setViewportSize({ width: viewportWidth, height: 800 })
|
||||
return keybindingList.evaluate((el) => el.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
const wideWidth = await listWidthAt(1280)
|
||||
const narrowWidth = await listWidthAt(560)
|
||||
|
||||
expect(narrowWidth).toBeLessThan(wideWidth)
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,37 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaUploadDropzone
|
||||
)
|
||||
const [fileChooser] = await Promise.all([
|
||||
comfyPage.page.waitForEvent('filechooser'),
|
||||
dropzone.click()
|
||||
])
|
||||
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
}
|
||||
|
||||
async function confirmPendingSelection(comfyPage: ComfyPage) {
|
||||
const confirmButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaConfirmButton
|
||||
)
|
||||
await expect(confirmButton).toBeEnabled()
|
||||
await confirmButton.click()
|
||||
}
|
||||
|
||||
function getMediaRow(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
|
||||
}
|
||||
|
||||
function getStatusCard(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
|
||||
}
|
||||
|
||||
function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
}
|
||||
|
||||
function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
@@ -81,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
test('Shows missing item label and locate action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -89,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Upload flow', () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Library select flow', () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
test.describe('List behavior', () => {
|
||||
test('Clicking the missing item label navigates canvas to the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -122,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
)
|
||||
await librarySelect.getByRole('combobox').click()
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
|
||||
const optionCount = await comfyPage.page.getByRole('option').count()
|
||||
if (optionCount === 0) {
|
||||
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
await comfyPage.page.getByRole('option').first().click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cancel selection', () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
await expect(getDropzone(comfyPage)).toBeHidden()
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
|
||||
await getMediaRow(comfyPage)
|
||||
.getByRole('button', { name: 'Load Image - image', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeHidden()
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('All resolved', () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
})
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
@@ -12,27 +12,39 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
test('Should show missing node pack card with guidance', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
await expect(
|
||||
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
test('Should show unknown pack node rows by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
|
||||
await expect(
|
||||
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show subgraph missing node rows by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -43,66 +55,72 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
missingNodeCard.getByRole('button', {
|
||||
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
test('Should locate missing node from the row label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
|
||||
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /collapse/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeHidden()
|
||||
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBeforeLocate)
|
||||
})
|
||||
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
test('Should toggle grouped pack nodes from chevron and title', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
'missing/missing_nodes_same_pack'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const locateButton = missingNodeCard.getByRole('button', {
|
||||
name: /locate/i
|
||||
const packTitle = missingNodeCard.getByRole('button', {
|
||||
name: 'test-missing-node-pack'
|
||||
})
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
// TODO: Add navigation assertion once subgraph node ID deduplication
|
||||
// timing is fixed. Currently, collectMissingNodes runs before
|
||||
// configure(), so execution IDs use pre-remapped node IDs that don't
|
||||
// match the runtime graph. See PR #9510 / #8762.
|
||||
const expandButton = missingNodeCard.getByTestId(
|
||||
TestIds.dialogs.missingNodePackExpand
|
||||
)
|
||||
const firstNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_A'
|
||||
})
|
||||
const secondNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_B'
|
||||
})
|
||||
|
||||
await expect(packTitle).toBeVisible()
|
||||
await expect(
|
||||
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
|
||||
).toHaveText('2')
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
|
||||
await expandButton.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -425,6 +425,56 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Refreshing a resolved promoted missing model clears the combo invalid state',
|
||||
{ tag: ['@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
.getByRole('combobox', { name: 'ckpt_name', exact: true })
|
||||
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
|
||||
|
||||
const objectInfoRoute = /\/object_info$/
|
||||
try {
|
||||
await comfyPage.page.route(objectInfoRoute, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
.click()
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
await expect(promotedModelCombo).toBeVisible()
|
||||
await expect(promotedModelCombo).not.toHaveAttribute(
|
||||
'aria-invalid',
|
||||
'true'
|
||||
)
|
||||
} finally {
|
||||
await comfyPage.page.unroute(objectInfoRoute)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -233,4 +233,64 @@ test.describe('Model library sidebar - empty state', () => {
|
||||
await expect(tab.folderNodes).toHaveCount(0)
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - add node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking a model defers creation until placed on the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
|
||||
test('Ghost preview shows the model in the loader widget before placing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
const ghost = comfyPage.page.locator(
|
||||
'[data-node-id="preview-CheckpointLoaderSimple"]'
|
||||
)
|
||||
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.10",
|
||||
"version": "1.46.12",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -28,13 +28,15 @@ export type {
|
||||
BillingPlansResponse,
|
||||
BillingStatus,
|
||||
BillingStatusResponse,
|
||||
BindingErrorResponse,
|
||||
BulkRevokeApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysData,
|
||||
BulkRevokeWorkspaceMemberApiKeysError,
|
||||
BulkRevokeWorkspaceMemberApiKeysErrors,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponses,
|
||||
CancelAssetSeedData,
|
||||
CancelAssetSeedResponse,
|
||||
CancelAssetSeedResponses,
|
||||
CancelJobData,
|
||||
CancelJobError,
|
||||
CancelJobErrors,
|
||||
@@ -57,11 +59,14 @@ export type {
|
||||
CheckHubUsernameResponse,
|
||||
CheckHubUsernameResponses,
|
||||
ClientOptions,
|
||||
CreateAssetData,
|
||||
CreateAssetDownloadData,
|
||||
CreateAssetDownloadError,
|
||||
CreateAssetDownloadErrors,
|
||||
CreateAssetDownloadResponse,
|
||||
CreateAssetDownloadResponses,
|
||||
CreateAssetError,
|
||||
CreateAssetErrors,
|
||||
CreateAssetExportData,
|
||||
CreateAssetExportError,
|
||||
CreateAssetExportErrors,
|
||||
@@ -72,6 +77,8 @@ export type {
|
||||
CreateAssetFromHashErrors,
|
||||
CreateAssetFromHashResponse,
|
||||
CreateAssetFromHashResponses,
|
||||
CreateAssetResponse,
|
||||
CreateAssetResponses,
|
||||
CreateDeletionRequestData,
|
||||
CreateDeletionRequestError,
|
||||
CreateDeletionRequestErrors,
|
||||
@@ -208,6 +215,8 @@ export type {
|
||||
ForkWorkflowRequest,
|
||||
ForkWorkflowResponse,
|
||||
ForkWorkflowResponses,
|
||||
FreeMemoryData,
|
||||
FreeMemoryResponses,
|
||||
GetAllSettingsData,
|
||||
GetAllSettingsError,
|
||||
GetAllSettingsErrors,
|
||||
@@ -221,6 +230,9 @@ export type {
|
||||
GetAssetByIdErrors,
|
||||
GetAssetByIdResponse,
|
||||
GetAssetByIdResponses,
|
||||
GetAssetSeedStatusData,
|
||||
GetAssetSeedStatusResponse,
|
||||
GetAssetSeedStatusResponses,
|
||||
GetAssetTagHistogramData,
|
||||
GetAssetTagHistogramError,
|
||||
GetAssetTagHistogramErrors,
|
||||
@@ -259,6 +271,9 @@ export type {
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetEmbeddingsData,
|
||||
GetEmbeddingsResponse,
|
||||
GetEmbeddingsResponses,
|
||||
GetExtensionsData,
|
||||
GetExtensionsResponse,
|
||||
GetExtensionsResponses,
|
||||
@@ -305,6 +320,18 @@ export type {
|
||||
GetHubWorkflowErrors,
|
||||
GetHubWorkflowResponse,
|
||||
GetHubWorkflowResponses,
|
||||
GetI18nData,
|
||||
GetI18nResponse,
|
||||
GetI18nResponses,
|
||||
GetInternalFolderPathsData,
|
||||
GetInternalFolderPathsResponse,
|
||||
GetInternalFolderPathsResponses,
|
||||
GetInternalLogsData,
|
||||
GetInternalLogsRawData,
|
||||
GetInternalLogsRawResponse,
|
||||
GetInternalLogsRawResponses,
|
||||
GetInternalLogsResponse,
|
||||
GetInternalLogsResponses,
|
||||
GetJobDetailData,
|
||||
GetJobDetailError,
|
||||
GetJobDetailErrors,
|
||||
@@ -356,10 +383,7 @@ export type {
|
||||
GetModelFoldersResponse,
|
||||
GetModelFoldersResponses,
|
||||
GetModelPreviewData,
|
||||
GetModelPreviewError,
|
||||
GetModelPreviewErrors,
|
||||
GetModelPreviewResponse,
|
||||
GetModelPreviewResponses,
|
||||
GetModelsInFolderData,
|
||||
GetModelsInFolderError,
|
||||
GetModelsInFolderErrors,
|
||||
@@ -389,8 +413,26 @@ export type {
|
||||
GetNodeReplacementsErrors,
|
||||
GetNodeReplacementsResponse,
|
||||
GetNodeReplacementsResponses,
|
||||
GetOpenapiSpecData,
|
||||
GetOpenapiSpecResponses,
|
||||
GetOAuthAuthorizationServerData,
|
||||
GetOAuthAuthorizationServerError,
|
||||
GetOAuthAuthorizationServerErrors,
|
||||
GetOAuthAuthorizationServerResponse,
|
||||
GetOAuthAuthorizationServerResponses,
|
||||
GetOAuthAuthorizeData,
|
||||
GetOAuthAuthorizeError,
|
||||
GetOAuthAuthorizeErrors,
|
||||
GetOAuthAuthorizeResponse,
|
||||
GetOAuthAuthorizeResponses,
|
||||
GetOAuthProtectedResourceByPathData,
|
||||
GetOAuthProtectedResourceByPathError,
|
||||
GetOAuthProtectedResourceByPathErrors,
|
||||
GetOAuthProtectedResourceByPathResponse,
|
||||
GetOAuthProtectedResourceByPathResponses,
|
||||
GetOAuthProtectedResourceData,
|
||||
GetOAuthProtectedResourceError,
|
||||
GetOAuthProtectedResourceErrors,
|
||||
GetOAuthProtectedResourceResponse,
|
||||
GetOAuthProtectedResourceResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
@@ -427,11 +469,11 @@ export type {
|
||||
GetSecretErrors,
|
||||
GetSecretResponse,
|
||||
GetSecretResponses,
|
||||
GetSettingByKeyData,
|
||||
GetSettingByKeyError,
|
||||
GetSettingByKeyErrors,
|
||||
GetSettingByKeyResponse,
|
||||
GetSettingByKeyResponses,
|
||||
GetSettingByIdData,
|
||||
GetSettingByIdError,
|
||||
GetSettingByIdErrors,
|
||||
GetSettingByIdResponse,
|
||||
GetSettingByIdResponses,
|
||||
GetStaticExtensionsData,
|
||||
GetStaticExtensionsErrors,
|
||||
GetStaticExtensionsResponses,
|
||||
@@ -447,6 +489,7 @@ export type {
|
||||
GetTaskResponses,
|
||||
GetTemplateProxyData,
|
||||
GetTemplateProxyErrors,
|
||||
GetTemplateProxyResponses,
|
||||
GetUserData,
|
||||
GetUserdataData,
|
||||
GetUserdataError,
|
||||
@@ -534,6 +577,11 @@ export type {
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InsertDynamicConfigData,
|
||||
InsertDynamicConfigError,
|
||||
InsertDynamicConfigErrors,
|
||||
InsertDynamicConfigResponse,
|
||||
InsertDynamicConfigResponses,
|
||||
InterruptJobData,
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
@@ -642,6 +690,17 @@ export type {
|
||||
MoveUserdataFileResponse,
|
||||
MoveUserdataFileResponses,
|
||||
NodeInfo,
|
||||
OAuthAuthorizationServerMetadata,
|
||||
OAuthAuthorizeRedirectResponse,
|
||||
OAuthConsentChallenge,
|
||||
OAuthConsentChallengeWorkspace,
|
||||
OAuthProtectedResourceMetadata,
|
||||
OAuthRegisterBadRequestResponse,
|
||||
OAuthRegisterError,
|
||||
OAuthRegisterRequest,
|
||||
OAuthRegisterResponse,
|
||||
OAuthTokenError,
|
||||
OAuthTokenResponse,
|
||||
PaginationInfo,
|
||||
PartnerUsageRequest,
|
||||
PartnerUsageResponse,
|
||||
@@ -663,6 +722,21 @@ export type {
|
||||
PostMonitoringTasksSubpathData,
|
||||
PostMonitoringTasksSubpathErrors,
|
||||
PostMonitoringTasksSubpathResponses,
|
||||
PostOAuthAuthorizeData,
|
||||
PostOAuthAuthorizeError,
|
||||
PostOAuthAuthorizeErrors,
|
||||
PostOAuthAuthorizeResponse,
|
||||
PostOAuthAuthorizeResponses,
|
||||
PostOAuthRegisterData,
|
||||
PostOAuthRegisterError,
|
||||
PostOAuthRegisterErrors,
|
||||
PostOAuthRegisterResponse,
|
||||
PostOAuthRegisterResponses,
|
||||
PostOAuthTokenData,
|
||||
PostOAuthTokenError,
|
||||
PostOAuthTokenErrors,
|
||||
PostOAuthTokenResponse,
|
||||
PostOAuthTokenResponses,
|
||||
PostPprofSymbolData,
|
||||
PostPprofSymbolResponses,
|
||||
PostUserdataFileData,
|
||||
@@ -687,6 +761,9 @@ export type {
|
||||
PromptInfo,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
PruneAssetsData,
|
||||
PruneAssetsResponse,
|
||||
PruneAssetsResponses,
|
||||
PublishedWorkflowDetail,
|
||||
PublishHubWorkflowData,
|
||||
PublishHubWorkflowError,
|
||||
@@ -732,6 +809,9 @@ export type {
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretResponse,
|
||||
SeedAssetsData,
|
||||
SeedAssetsResponse,
|
||||
SeedAssetsResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
@@ -751,6 +831,8 @@ export type {
|
||||
SubscribeResponse,
|
||||
SubscribeResponse2,
|
||||
SubscribeResponses,
|
||||
SubscribeToLogsData,
|
||||
SubscribeToLogsResponses,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
SyncApiKeyData,
|
||||
@@ -771,11 +853,6 @@ export type {
|
||||
UpdateAssetErrors,
|
||||
UpdateAssetResponse,
|
||||
UpdateAssetResponses,
|
||||
UpdateAssetTagsData,
|
||||
UpdateAssetTagsError,
|
||||
UpdateAssetTagsErrors,
|
||||
UpdateAssetTagsResponse,
|
||||
UpdateAssetTagsResponses,
|
||||
UpdateHubProfileData,
|
||||
UpdateHubProfileError,
|
||||
UpdateHubProfileErrors,
|
||||
@@ -799,11 +876,11 @@ export type {
|
||||
UpdateSecretRequest,
|
||||
UpdateSecretResponse,
|
||||
UpdateSecretResponses,
|
||||
UpdateSettingByKeyData,
|
||||
UpdateSettingByKeyError,
|
||||
UpdateSettingByKeyErrors,
|
||||
UpdateSettingByKeyResponse,
|
||||
UpdateSettingByKeyResponses,
|
||||
UpdateSettingByIdData,
|
||||
UpdateSettingByIdError,
|
||||
UpdateSettingByIdErrors,
|
||||
UpdateSettingByIdResponse,
|
||||
UpdateSettingByIdResponses,
|
||||
UpdateSubscriptionCacheData,
|
||||
UpdateSubscriptionCacheError,
|
||||
UpdateSubscriptionCacheErrors,
|
||||
@@ -821,11 +898,6 @@ export type {
|
||||
UpdateWorkspaceRequest,
|
||||
UpdateWorkspaceResponse,
|
||||
UpdateWorkspaceResponses,
|
||||
UploadAssetData,
|
||||
UploadAssetError,
|
||||
UploadAssetErrors,
|
||||
UploadAssetResponse,
|
||||
UploadAssetResponses,
|
||||
UploadImageData,
|
||||
UploadImageError,
|
||||
UploadImageErrors,
|
||||
|
||||
1263
packages/ingest-types/src/types.gen.ts
generated
1263
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
602
packages/ingest-types/src/zod.gen.ts
generated
602
packages/ingest-types/src/zod.gen.ts
generated
@@ -399,13 +399,18 @@ export const zCreateWorkflowVersionRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Offset/limit-based pagination metadata included in list responses.
|
||||
* Pagination metadata included in list responses. Supports both legacy
|
||||
* offset/limit pagination and cursor-based pagination. When cursor-based
|
||||
* pagination is used, `next_cursor` is the primary pagination token and
|
||||
* `offset`/`total` may be zero.
|
||||
*
|
||||
*/
|
||||
export const zPaginationInfo = z.object({
|
||||
offset: z.number().int().gte(0),
|
||||
limit: z.number().int().gte(1),
|
||||
total: z.number().int().gte(0),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -879,6 +884,155 @@ export const zJwkKey = z.object({
|
||||
y: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.2 error response.
|
||||
*/
|
||||
export const zOAuthTokenError = z.object({
|
||||
error: z.string(),
|
||||
error_description: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.1 successful token response.
|
||||
*/
|
||||
export const zOAuthTokenResponse = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.enum(['Bearer']),
|
||||
expires_in: z.number().int(),
|
||||
refresh_token: z.string(),
|
||||
scope: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
|
||||
*
|
||||
*/
|
||||
export const zOAuthConsentChallengeWorkspace = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['personal', 'team']),
|
||||
role: z.enum(['owner', 'member'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
|
||||
*/
|
||||
export const zOAuthAuthorizeRedirectResponse = z.object({
|
||||
redirect_url: z.string().url()
|
||||
})
|
||||
|
||||
/**
|
||||
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
|
||||
*
|
||||
*/
|
||||
export const zOAuthConsentChallenge = z.object({
|
||||
oauth_request_id: z.string().uuid(),
|
||||
csrf_token: z.string(),
|
||||
client_display_name: z.string(),
|
||||
resource_display_name: z.string(),
|
||||
scopes: z.array(z.string()),
|
||||
workspaces: z.array(zOAuthConsentChallengeWorkspace)
|
||||
})
|
||||
|
||||
/**
|
||||
* OAuth 2.1 protected-resource metadata (RFC 9728).
|
||||
*/
|
||||
export const zOAuthProtectedResourceMetadata = z.object({
|
||||
resource: z.string().url(),
|
||||
authorization_servers: z.array(z.string().url()),
|
||||
scopes_supported: z.array(z.string()),
|
||||
bearer_methods_supported: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.2 error response.
|
||||
*/
|
||||
export const zOAuthRegisterError = z.object({
|
||||
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
|
||||
error_description: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* Standard error response with a machine-readable code and human-readable message.
|
||||
*/
|
||||
export const zErrorResponse = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `ErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs, normalized to the standard {code, message} shape by the custom Echo HTTPErrorHandler (BE-1178).
|
||||
*
|
||||
*/
|
||||
export const zOAuthRegisterBadRequestResponse = z.union([
|
||||
zOAuthRegisterError,
|
||||
zErrorResponse
|
||||
])
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.1 successful registration response.
|
||||
*/
|
||||
export const zOAuthRegisterResponse = z.object({
|
||||
client_id: z.string(),
|
||||
client_id_issued_at: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
client_name: z.string().optional(),
|
||||
redirect_uris: z.array(z.string()),
|
||||
grant_types: z.array(z.string()),
|
||||
response_types: z.array(z.string()),
|
||||
token_endpoint_auth_method: z.enum(['none']),
|
||||
application_type: z.enum(['native', 'web'])
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
|
||||
*
|
||||
*/
|
||||
export const zOAuthRegisterRequest = z.object({
|
||||
redirect_uris: z.array(z.string()).min(1).max(5),
|
||||
client_name: z.string().max(100).optional(),
|
||||
application_type: z.enum(['native', 'web']).optional(),
|
||||
token_endpoint_auth_method: z.enum(['none']).optional(),
|
||||
grant_types: z
|
||||
.array(z.enum(['authorization_code', 'refresh_token']))
|
||||
.optional(),
|
||||
response_types: z.array(z.enum(['code'])).optional(),
|
||||
scope: z.string().nullish(),
|
||||
resource_grants: z.record(z.array(z.string())).nullish(),
|
||||
client_uri: z.string().nullish(),
|
||||
logo_uri: z.string().nullish(),
|
||||
tos_uri: z.string().nullish(),
|
||||
policy_uri: z.string().nullish(),
|
||||
software_id: z.string().nullish(),
|
||||
software_version: z.string().nullish(),
|
||||
contacts: z.array(z.string()).nullish(),
|
||||
jwks: z.record(z.unknown()).nullish(),
|
||||
jwks_uri: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* OAuth 2.1 authorization-server metadata (RFC 8414).
|
||||
*/
|
||||
export const zOAuthAuthorizationServerMetadata = z.object({
|
||||
issuer: z.string().url(),
|
||||
authorization_endpoint: z.string().url(),
|
||||
token_endpoint: z.string().url(),
|
||||
jwks_uri: z.string().url(),
|
||||
registration_endpoint: z.string().url().optional(),
|
||||
response_types_supported: z.array(z.string()),
|
||||
grant_types_supported: z.array(z.string()),
|
||||
code_challenge_methods_supported: z.array(z.string()),
|
||||
token_endpoint_auth_methods_supported: z.array(z.string()),
|
||||
scopes_supported: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
|
||||
*/
|
||||
@@ -940,6 +1094,7 @@ export const zWorkspaceApiKeyInfo = z.object({
|
||||
workspace_id: z.string(),
|
||||
user_id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().max(5000),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
last_used_at: z.string().datetime().optional(),
|
||||
@@ -960,6 +1115,7 @@ export const zListWorkspaceApiKeysResponse = z.object({
|
||||
export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().max(5000),
|
||||
key: z.string(),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
@@ -971,6 +1127,7 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
*/
|
||||
export const zCreateWorkspaceApiKeyRequest = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().max(5000).optional(),
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
@@ -1353,7 +1510,8 @@ export const zListTagsResponse = z.object({
|
||||
export const zAsset = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
asset_hash: z
|
||||
display_name: z.string().nullish(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
@@ -1364,19 +1522,20 @@ export const zAsset = z.object({
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
mime_type: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).readonly().optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
prompt_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
last_access_time: z.string().datetime().optional(),
|
||||
is_immutable: z.boolean().optional()
|
||||
is_immutable: z.boolean().optional(),
|
||||
file_path: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1385,7 +1544,8 @@ export const zAsset = z.object({
|
||||
export const zListAssetsResponse = z.object({
|
||||
assets: z.array(zAsset),
|
||||
total: z.number().int(),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1394,14 +1554,17 @@ export const zListAssetsResponse = z.object({
|
||||
export const zAssetUpdated = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().optional(),
|
||||
asset_hash: z
|
||||
display_name: z.string().nullish(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
mime_type: z.string().optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
updated_at: z.string().datetime()
|
||||
job_id: z.string().uuid().nullish(),
|
||||
updated_at: z.string().datetime(),
|
||||
file_path: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1753,21 +1916,6 @@ export const zExportDownloadUrlResponse = z.object({
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Error shape returned when request binding or validation fails before the handler runs.
|
||||
*/
|
||||
export const zBindingErrorResponse = z.object({
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Standard error response with a machine-readable code and human-readable message.
|
||||
*/
|
||||
export const zErrorResponse = z.object({
|
||||
code: z.string(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response returned after successfully queuing a workflow prompt.
|
||||
*/
|
||||
@@ -1796,7 +1944,8 @@ export const zPromptRequest = z.object({
|
||||
export const zAssetWritable = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
asset_hash: z
|
||||
display_name: z.string().nullish(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
@@ -1807,18 +1956,19 @@ export const zAssetWritable = z.object({
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
mime_type: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
prompt_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
last_access_time: z.string().datetime().optional(),
|
||||
is_immutable: z.boolean().optional()
|
||||
is_immutable: z.boolean().optional(),
|
||||
file_path: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1827,7 +1977,8 @@ export const zAssetWritable = z.object({
|
||||
export const zListAssetsResponseWritable = z.object({
|
||||
assets: z.array(zAssetWritable),
|
||||
total: z.number().int(),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1961,21 +2112,6 @@ export const zGetModelsInFolderData = z.object({
|
||||
*/
|
||||
export const zGetModelsInFolderResponse = z.array(zModelFile)
|
||||
|
||||
export const zGetModelPreviewData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
folder: z.string(),
|
||||
path_index: z.number().int(),
|
||||
filename: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Model preview image
|
||||
*/
|
||||
export const zGetModelPreviewResponse = z.string()
|
||||
|
||||
export const zGetLegacyHistoryData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2027,6 +2163,7 @@ export const zListJobsData = z.object({
|
||||
output_type: z.enum(['image', 'video', 'audio', '3d']).optional(),
|
||||
sort_by: z.enum(['create_time', 'execution_time']).optional(),
|
||||
sort_order: z.enum(['asc', 'desc']).optional(),
|
||||
after: z.string().optional(),
|
||||
offset: z.number().int().gte(0).optional().default(0),
|
||||
limit: z.number().int().gte(1).lte(1000).optional().default(100)
|
||||
})
|
||||
@@ -2132,9 +2269,9 @@ export const zListAssetsData = z.object({
|
||||
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
|
||||
.optional(),
|
||||
order: z.enum(['asc', 'desc']).optional(),
|
||||
job_ids: z.array(z.string().uuid()).optional(),
|
||||
include_public: z.boolean().optional().default(true),
|
||||
asset_hash: z.string().optional()
|
||||
hash: z.string().optional(),
|
||||
after: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -2144,26 +2281,34 @@ export const zListAssetsData = z.object({
|
||||
*/
|
||||
export const zListAssetsResponse2 = zListAssetsResponse
|
||||
|
||||
export const zUploadAssetData = z.object({
|
||||
export const zCreateAssetData = z.object({
|
||||
body: z.object({
|
||||
url: z.string().url(),
|
||||
name: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_id: z.string().uuid().optional()
|
||||
file: z.string(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^(blake3|sha256):[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
tags: z.string().optional(),
|
||||
id: z.string().uuid().optional(),
|
||||
preview_id: z.string().uuid().optional(),
|
||||
name: z.string().optional(),
|
||||
mime_type: z.string().optional(),
|
||||
user_metadata: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset already exists (returned existing asset)
|
||||
* Asset already existed for this user (deduplicated by content hash); the
|
||||
* existing asset is returned with created_new=false.
|
||||
*
|
||||
*/
|
||||
export const zUploadAssetResponse = zAssetCreated
|
||||
export const zCreateAssetResponse = zAssetCreated
|
||||
|
||||
export const zCreateAssetFromHashData = z.object({
|
||||
body: z.object({
|
||||
hash: z.string().regex(/^(blake3|sha256):[a-f0-9]{64}$/),
|
||||
hash: z.string().regex(/^blake3:[a-f0-9]{64}$/),
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).min(1),
|
||||
mime_type: z.string().optional(),
|
||||
@@ -2174,7 +2319,9 @@ export const zCreateAssetFromHashData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset reference already exists (returned existing)
|
||||
* Asset reference already existed for this user (deduplicated by content
|
||||
* hash); the existing asset is returned with created_new=false.
|
||||
*
|
||||
*/
|
||||
export const zCreateAssetFromHashResponse = zAssetCreated
|
||||
|
||||
@@ -2214,7 +2361,8 @@ export const zCreateAssetExportData = z.object({
|
||||
naming_strategy: z
|
||||
.enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time'])
|
||||
.optional(),
|
||||
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
|
||||
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional(),
|
||||
include_previews: z.boolean().optional().default(false)
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
@@ -2247,7 +2395,7 @@ export const zDeleteAssetData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset deleted successfully
|
||||
* Asset record deleted successfully
|
||||
*/
|
||||
export const zDeleteAssetResponse = z.void()
|
||||
|
||||
@@ -2312,22 +2460,6 @@ export const zAddAssetTagsData = z.object({
|
||||
*/
|
||||
export const zAddAssetTagsResponse = zTagsModificationResponse
|
||||
|
||||
export const zUpdateAssetTagsData = z.object({
|
||||
body: z.object({
|
||||
add: z.array(z.string()).optional(),
|
||||
remove: z.array(z.string()).optional()
|
||||
}),
|
||||
path: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Tags updated successfully
|
||||
*/
|
||||
export const zUpdateAssetTagsResponse = zTagsModificationResponse
|
||||
|
||||
export const zListTagsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2509,10 +2641,10 @@ export const zUpdateMultipleSettingsData = z.object({
|
||||
*/
|
||||
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetSettingByKeyData = z.object({
|
||||
export const zGetSettingByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
key: z.string()
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
@@ -2520,14 +2652,14 @@ export const zGetSettingByKeyData = z.object({
|
||||
/**
|
||||
* Setting value response
|
||||
*/
|
||||
export const zGetSettingByKeyResponse = z.object({
|
||||
export const zGetSettingByIdResponse = z.object({
|
||||
value: z.unknown().optional()
|
||||
})
|
||||
|
||||
export const zUpdateSettingByKeyData = z.object({
|
||||
export const zUpdateSettingByIdData = z.object({
|
||||
body: z.unknown(),
|
||||
path: z.object({
|
||||
key: z.string()
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
@@ -2535,7 +2667,7 @@ export const zUpdateSettingByKeyData = z.object({
|
||||
/**
|
||||
* Updated setting value response
|
||||
*/
|
||||
export const zUpdateSettingByKeyResponse = z.object({
|
||||
export const zUpdateSettingByIdResponse = z.object({
|
||||
value: z.unknown().optional()
|
||||
})
|
||||
|
||||
@@ -2691,21 +2823,7 @@ export const zUploadMaskData = z.object({
|
||||
export const zUploadMaskResponse = z.object({
|
||||
name: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
is_mask: z.boolean().optional(),
|
||||
original_hash: z.string().optional(),
|
||||
mask_type: z.string().optional(),
|
||||
related_files: z
|
||||
.object({
|
||||
mask: z.string().optional(),
|
||||
paint: z.string().optional(),
|
||||
painted: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional()
|
||||
type: z.string().optional()
|
||||
})
|
||||
|
||||
export const zGetLogsData = z.object({
|
||||
@@ -2774,6 +2892,115 @@ export const zGetJwksData = z.object({
|
||||
*/
|
||||
export const zGetJwksResponse = zJwksResponse
|
||||
|
||||
export const zGetOAuthAuthorizationServerData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Authorization-server metadata
|
||||
*/
|
||||
export const zGetOAuthAuthorizationServerResponse =
|
||||
zOAuthAuthorizationServerMetadata
|
||||
|
||||
export const zGetOAuthProtectedResourceData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Protected-resource metadata
|
||||
*/
|
||||
export const zGetOAuthProtectedResourceResponse =
|
||||
zOAuthProtectedResourceMetadata
|
||||
|
||||
export const zGetOAuthProtectedResourceByPathData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
resourcePath: z.string().regex(/^[a-zA-Z0-9._-]+$/)
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Protected-resource metadata
|
||||
*/
|
||||
export const zGetOAuthProtectedResourceByPathResponse =
|
||||
zOAuthProtectedResourceMetadata
|
||||
|
||||
export const zGetOAuthAuthorizeData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
response_type: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.string().optional(),
|
||||
resource: z.string().optional(),
|
||||
oauth_request_id: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
|
||||
*
|
||||
*/
|
||||
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
|
||||
|
||||
export const zPostOAuthAuthorizeData = z.object({
|
||||
body: z.object({
|
||||
oauth_request_id: z.string().uuid(),
|
||||
csrf_token: z.string(),
|
||||
decision: z.enum(['allow', 'deny']),
|
||||
workspace_id: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
|
||||
*/
|
||||
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
|
||||
|
||||
export const zPostOAuthTokenData = z.object({
|
||||
body: z.object({
|
||||
grant_type: z.enum(['authorization_code', 'refresh_token']),
|
||||
client_id: z.string(),
|
||||
code: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
client_secret: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* New token pair
|
||||
*/
|
||||
export const zPostOAuthTokenResponse = zOAuthTokenResponse
|
||||
|
||||
export const zPostOAuthRegisterData = z.object({
|
||||
body: zOAuthRegisterRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
|
||||
*/
|
||||
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
|
||||
|
||||
export const zListWorkspacesData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3078,6 +3305,28 @@ export const zUpdateSubscriptionCacheResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zInsertDynamicConfigData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Config inserted successfully
|
||||
*/
|
||||
export const zInsertDynamicConfigResponse = z.object({
|
||||
id: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
message: z.string().optional()
|
||||
})
|
||||
|
||||
export const zSyncApiKeyData = z.object({
|
||||
body: zSyncApiKeyRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -3671,12 +3920,6 @@ export const zGetHealthData = z.object({
|
||||
*/
|
||||
export const zGetHealthResponse = z.string()
|
||||
|
||||
export const zGetOpenapiSpecData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetMonitoringTasksData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3757,6 +4000,16 @@ export const zPostCustomNodeProxyData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetModelPreviewData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
folder: z.string(),
|
||||
path_index: z.number().int(),
|
||||
filename: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyPromptByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -3832,3 +4085,150 @@ export const zGetLegacyViewMetadataData = z.object({
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetEmbeddingsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Embedding names
|
||||
*/
|
||||
export const zGetEmbeddingsResponse = z.array(z.string())
|
||||
|
||||
export const zFreeMemoryData = z.object({
|
||||
body: z
|
||||
.object({
|
||||
unload_models: z.boolean().optional(),
|
||||
free_memory: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetI18nData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Nested map of locale to translation key-value pairs
|
||||
*/
|
||||
export const zGetI18nResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetInternalFolderPathsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Map of folder type name to list of path entries
|
||||
*/
|
||||
export const zGetInternalFolderPathsResponse = z.record(
|
||||
z.array(z.array(z.string()))
|
||||
)
|
||||
|
||||
export const zGetInternalLogsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Log text
|
||||
*/
|
||||
export const zGetInternalLogsResponse = z.string()
|
||||
|
||||
export const zGetInternalLogsRawData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Structured log data
|
||||
*/
|
||||
export const zGetInternalLogsRawResponse = z.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
t: z.number().optional(),
|
||||
m: z.string().optional()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
size: z
|
||||
.object({
|
||||
cols: z.number().int().optional(),
|
||||
rows: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zSubscribeToLogsData = z.object({
|
||||
body: z.object({
|
||||
clientId: z.string(),
|
||||
enabled: z.boolean()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zPruneAssetsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Prune result
|
||||
*/
|
||||
export const zPruneAssetsResponse = z.object({
|
||||
status: z.string().optional(),
|
||||
marked: z.number().int().optional()
|
||||
})
|
||||
|
||||
export const zSeedAssetsData = z.object({
|
||||
body: z
|
||||
.object({
|
||||
roots: z.array(z.string()).optional()
|
||||
})
|
||||
.optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Seed started
|
||||
*/
|
||||
export const zSeedAssetsResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zGetAssetSeedStatusData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Scan progress details (files scanned, total, status, etc.)
|
||||
*/
|
||||
export const zGetAssetSeedStatusResponse = z.record(z.unknown())
|
||||
|
||||
export const zCancelAssetSeedData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Scan cancelled
|
||||
*/
|
||||
export const zCancelAssetSeedResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<svg width="520" height="520" viewBox="0 0 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_227_285" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="520" height="520">
|
||||
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#EEFF30"/>
|
||||
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#F2FF59"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<rect y="0.751831" width="520" height="520" fill="#172DD7"/>
|
||||
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F0FF41"/>
|
||||
<rect y="0.751831" width="520" height="520" fill="#211927"/>
|
||||
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -2,8 +2,8 @@
|
||||
"PreviewImage": 4314,
|
||||
"LoadImage": 3474,
|
||||
"CLIPTextEncode": 2435,
|
||||
"SaveImageAdvanced": 1763,
|
||||
"SaveImage": 1762,
|
||||
"SaveImageAdvanced": 1762,
|
||||
"VAEDecode": 1754,
|
||||
"KSampler": 1511,
|
||||
"CheckpointLoaderSimple": 1293,
|
||||
@@ -14,6 +14,7 @@
|
||||
"UpscaleModelLoader": 629,
|
||||
"UNETLoader": 606,
|
||||
"VAELoader": 604,
|
||||
"PreviewAny": 528,
|
||||
"ShowText|pysssss": 527.5526981023964,
|
||||
"ImageUpscaleWithModel": 523,
|
||||
"ControlNetApplyAdvanced": 513,
|
||||
@@ -24,10 +25,12 @@
|
||||
"VHS_LoadVideo": 440,
|
||||
"ImpactSwitch": 349,
|
||||
"Reroute": 348,
|
||||
"ResizeImageMaskNode": 337,
|
||||
"ResizeAndPadImage": 336,
|
||||
"ImageResizeKJv2": 335,
|
||||
"StringConcatenate": 326,
|
||||
"Text Concatenate": 325.7030402103206,
|
||||
"SaveVideo": 321,
|
||||
"PreviewAny": 319,
|
||||
"KSamplerAdvanced": 304,
|
||||
"SDXLPromptStyler": 297.0913411304729,
|
||||
"Note": 291,
|
||||
@@ -52,6 +55,7 @@
|
||||
"CLIPLoader": 202,
|
||||
"GeminiNode": 202,
|
||||
"KSampler (Efficient)": 194.01083622636423,
|
||||
"RemoveBackground": 187,
|
||||
"ImageRemoveBackground+": 186,
|
||||
"IPAdapterModelLoader": 184,
|
||||
"PrimitiveInt": 183,
|
||||
@@ -59,7 +63,9 @@
|
||||
"LoadVideo": 179,
|
||||
"Text Concatenate (JPS)": 175.98154639522735,
|
||||
"PrimitiveNode": 175,
|
||||
"Text Multiline": 163.04749064680308,
|
||||
"PrimitiveStringMultiline": 166,
|
||||
"Text Multiline": 165,
|
||||
"GetImageSize": 164,
|
||||
"GetImageSize+": 163,
|
||||
"ImageScaleToTotalPixels": 157,
|
||||
"String Literal": 150.11343489837878,
|
||||
@@ -68,15 +74,14 @@
|
||||
"DownloadAndLoadFlorence2Model": 144,
|
||||
"LoadImageOutput": 143,
|
||||
"IPAdapterUnifiedLoader": 141,
|
||||
"FluxGuidance": 133,
|
||||
"BatchImagesNode": 134,
|
||||
"ImageBatchMulti": 133,
|
||||
"FluxGuidance": 132,
|
||||
"ByteDanceSeedreamNode": 130,
|
||||
"CR Text Input Switch": 128.16473423438606,
|
||||
"IPAdapterAdvanced": 128,
|
||||
"If ANY execute A else B": 127.77279315110049,
|
||||
"GeminiImage2Node": 124,
|
||||
"GetImageSize": 121,
|
||||
"PrimitiveStringMultiline": 120,
|
||||
"IPAdapter": 118,
|
||||
"CreateVideo": 116,
|
||||
"ConditioningZeroOut": 115,
|
||||
@@ -102,6 +107,7 @@
|
||||
"DepthAnythingPreprocessor": 100,
|
||||
"CR Apply LoRA Stack": 96.02556540496816,
|
||||
"Image Filter Adjustments": 95.24168323839699,
|
||||
"ComfyMathExpression": 96,
|
||||
"SimpleMath+": 95,
|
||||
"GroundingDinoSAMSegment (segment anything)": 93.28197782196906,
|
||||
"Image Overlay": 93.28197782196906,
|
||||
@@ -147,7 +153,6 @@
|
||||
"Image Resize": 63.494455492264656,
|
||||
"Automatic CFG": 63.494455492264656,
|
||||
"Canny": 63,
|
||||
"StringConcatenate": 63,
|
||||
"DepthAnything_V2": 61,
|
||||
"ImageCrop+": 60,
|
||||
"ModelSamplingSD3": 59,
|
||||
@@ -199,6 +204,7 @@
|
||||
"BNK_CLIPTextEncodeAdvanced": 45.857106744413365,
|
||||
"CR SDXL Aspect Ratio": 45.46516566112778,
|
||||
"LoadAudio": 45,
|
||||
"ResolutionSelector": 45,
|
||||
"smZ CLIPTextEncode": 44.68128349455661,
|
||||
"Bus Node": 44.68128349455661,
|
||||
"PreviewTextNode": 44.68128349455661,
|
||||
@@ -389,7 +395,6 @@
|
||||
"SD_4XUpscale_Conditioning": 21,
|
||||
"UltimateSDUpscaleCustomSample": 21,
|
||||
"StyleModelLoader": 21,
|
||||
"ResizeAndPadImage": 21,
|
||||
"Text Random Prompt": 20.77287741413597,
|
||||
"INPAINT_VAEEncodeInpaintConditioning": 20.77287741413597,
|
||||
"BrushNet": 20.77287741413597,
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
@@ -51,5 +51,5 @@ const {
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const { error } = useImage(computed(() => ({ src, alt })))
|
||||
const { error } = useImageQuiet(computed(() => ({ src, alt })))
|
||||
</script>
|
||||
|
||||
@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await fireEvent.dragStart(nodeDiv)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
|
||||
})
|
||||
|
||||
it('does not call startDrag for folder items on dragstart', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
class="keybinding-panel flex min-w-0 flex-col gap-2 overflow-x-hidden"
|
||||
>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
@@ -46,7 +46,10 @@
|
||||
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div @contextmenu.capture="clearContextMenuTarget">
|
||||
<div
|
||||
class="min-w-0 overflow-x-hidden"
|
||||
@contextmenu.capture="clearContextMenuTarget"
|
||||
>
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
v-model:expanded-rows="expandedRows"
|
||||
@@ -60,6 +63,7 @@
|
||||
selection-mode="single"
|
||||
context-menu
|
||||
striped-rows
|
||||
:table-style="{ tableLayout: 'fixed', width: '100%' }"
|
||||
:pt="{
|
||||
header: 'px-0'
|
||||
}"
|
||||
@@ -71,12 +75,11 @@
|
||||
field="id"
|
||||
: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="flex items-center gap-1 truncate"
|
||||
class="flex min-w-0 items-center gap-1 truncate"
|
||||
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
|
||||
:title="slotProps.data.id"
|
||||
>
|
||||
@@ -103,53 +106,38 @@
|
||||
<Column
|
||||
field="keybindings"
|
||||
:header="$t('g.keybinding')"
|
||||
:style="{ width: '30%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
v-if="slotProps.data.keybindings.length > 0"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<template
|
||||
v-for="(binding, idx) in (
|
||||
slotProps.data as ICommandData
|
||||
).keybindings.slice(0, 2)"
|
||||
:key="binding.combo.serialize()"
|
||||
>
|
||||
<span v-if="idx > 0" class="text-muted-foreground">,</span>
|
||||
<KeyComboDisplay
|
||||
:key-combo="binding.combo"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="slotProps.data.keybindings.length > 2"
|
||||
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('g.nMoreKeybindings', {
|
||||
count: slotProps.data.keybindings.length - 2
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
<KeybindingList
|
||||
:keybindings="slotProps.data.keybindings"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:style="{ width: '16%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
<span class="block truncate" :title="slotProps.data.source">{{
|
||||
slotProps.data.source || '-'
|
||||
}}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<Column
|
||||
field="actions"
|
||||
header=""
|
||||
:style="{ width: '9rem' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8 whitespace-nowrap' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div class="actions flex flex-row justify-end">
|
||||
<div
|
||||
class="actions flex flex-row justify-end whitespace-nowrap"
|
||||
>
|
||||
<Button
|
||||
v-if="slotProps.data.keybindings.length === 1"
|
||||
v-tooltip="$t('g.edit')"
|
||||
@@ -330,6 +318,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import KeybindingList from './keybinding/KeybindingList.vue'
|
||||
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeybindingList from './KeybindingList.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
nMoreKeybindings: '+ {count} more',
|
||||
nMoreKeybindingsCompact: '+ {count}',
|
||||
keybindingListAriaLabel: 'Keybindings: {combos}'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeKeybinding(key: string, ctrl = false, shift = false) {
|
||||
return new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key, ctrl, shift }
|
||||
})
|
||||
}
|
||||
|
||||
function renderList(props: {
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}) {
|
||||
return render(KeybindingList, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('KeybindingList', () => {
|
||||
it('renders "-" placeholder when there are no keybindings', () => {
|
||||
renderList({ keybindings: [] })
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('keybinding-list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a single keybinding without any "more" badge', () => {
|
||||
renderList({ keybindings: [makeKeybinding('A', true)] })
|
||||
expect(screen.getByTestId('keybinding-list')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-medium')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-compact')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('with 2 keybindings: omits wide-tier badge, shows medium/compact for narrow widths', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 1')
|
||||
})
|
||||
|
||||
it('with 3 keybindings: wide-tier uses count-minus-two, narrower tiers use count-minus-one', () => {
|
||||
renderList({
|
||||
keybindings: [
|
||||
makeKeybinding('A', true),
|
||||
makeKeybinding('B', true),
|
||||
makeKeybinding('C', true)
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-wide')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 2 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 2')
|
||||
})
|
||||
|
||||
it('uses a container query parent so the visible tier can adapt to width', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
expect(screen.getByTestId('keybinding-list').className).toContain(
|
||||
'@container/keybindings'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits an accessible label listing all combos', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true, true)]
|
||||
})
|
||||
const ariaText = screen.getByTestId('keybinding-list-aria').textContent
|
||||
expect(ariaText).toContain('Keybindings:')
|
||||
expect(ariaText).toContain('Ctrl')
|
||||
expect(ariaText).toContain('A')
|
||||
expect(ariaText).toContain('Shift')
|
||||
expect(ariaText).toContain('B')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="keybindings.length > 0"
|
||||
class="@container/keybindings flex w-full min-w-0 items-center gap-1 overflow-hidden"
|
||||
data-testid="keybinding-list"
|
||||
>
|
||||
<KeyComboDisplay
|
||||
:key-combo="keybindings[0].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
<template v-if="keybindings.length >= 2">
|
||||
<span
|
||||
class="hidden text-muted-foreground @[16rem]/keybindings:inline"
|
||||
aria-hidden="true"
|
||||
>
|
||||
,
|
||||
</span>
|
||||
<KeyComboDisplay
|
||||
class="hidden @[16rem]/keybindings:inline-flex"
|
||||
:key-combo="keybindings[1].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="keybindings.length > 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[16rem]/keybindings:inline"
|
||||
data-testid="keybinding-list-more-wide"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 2 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[12rem]/keybindings:inline @[16rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-medium"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[8rem]/keybindings:inline @[12rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-compact"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindingsCompact', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span class="sr-only" data-testid="keybinding-list-aria">
|
||||
{{ ariaLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeyComboDisplay from './KeyComboDisplay.vue'
|
||||
|
||||
const { keybindings, isModified = false } = defineProps<{
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const ariaLabel = computed(() => {
|
||||
if (keybindings.length === 0) return ''
|
||||
const combos = keybindings
|
||||
.map((binding) => binding.combo.toString())
|
||||
.join(', ')
|
||||
return t('g.keybindingListAriaLabel', { combos })
|
||||
})
|
||||
</script>
|
||||
@@ -93,6 +93,7 @@
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<NodeDragPreview />
|
||||
<VueNodeSwitchPopup />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
@@ -136,6 +137,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
|
||||
57
src/components/graph/NodeDragPreview.vue
Normal file
57
src/components/graph/NodeDragPreview.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showGhost && rafPosition"
|
||||
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
|
||||
:style="{
|
||||
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview
|
||||
:node-def="draggedNode!"
|
||||
:widget-values="pendingWidgetValues"
|
||||
position="relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMouse, useRafFn } from '@vueuse/core'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
|
||||
|
||||
const { x, y, sourceType } = useMouse({ type: 'client' })
|
||||
|
||||
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
|
||||
const rafPosition = shallowRef<{ x: number; y: number }>()
|
||||
|
||||
const { pause, resume } = useRafFn(
|
||||
() => {
|
||||
if (sourceType.value === null) return
|
||||
const pos = rafPosition.value
|
||||
if (pos && pos.x === x.value && pos.y === y.value) return
|
||||
rafPosition.value = { x: x.value, y: y.value }
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
watch(
|
||||
showGhost,
|
||||
(show) => {
|
||||
if (show) {
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
rafPosition.value = undefined
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -71,12 +71,11 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
|
||||
name: 'MissingPackGroupRow',
|
||||
template: `<div class="pack-row" data-testid="pack-row"
|
||||
:data-show-info-button="String(showInfoButton)"
|
||||
:data-show-node-id-badge="String(showNodeIdBadge)"
|
||||
>
|
||||
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
|
||||
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
|
||||
</div>`,
|
||||
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
|
||||
props: ['group', 'showInfoButton'],
|
||||
emits: ['locate-node', 'open-manager-info']
|
||||
}
|
||||
}))
|
||||
@@ -122,7 +121,6 @@ function makePackGroups(count = 2): MissingPackGroup[] {
|
||||
function renderCard(
|
||||
props: Partial<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}> = {}
|
||||
) {
|
||||
@@ -130,7 +128,6 @@ function renderCard(
|
||||
const result = render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
...props
|
||||
},
|
||||
@@ -169,12 +166,10 @@ describe('MissingNodeCard', () => {
|
||||
|
||||
it('passes props correctly to MissingPackGroupRow children', () => {
|
||||
renderCard({
|
||||
showInfoButton: true,
|
||||
showNodeIdBadge: true
|
||||
showInfoButton: true
|
||||
})
|
||||
const row = screen.getAllByTestId('pack-row')[0]
|
||||
expect(row.getAttribute('data-show-info-button')).toBe('true')
|
||||
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,7 +251,6 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onLocateNode
|
||||
},
|
||||
@@ -279,7 +273,6 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onOpenManagerInfo
|
||||
},
|
||||
|
||||
@@ -56,27 +56,29 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
|
||||
<div v-if="shouldShowManagerButtons" class="px-4">
|
||||
<Button
|
||||
v-if="hasInstalledPacksPendingRestart"
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
@@ -105,9 +107,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||
|
||||
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
|
||||
const { showInfoButton, missingPackGroups } = defineProps<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}>()
|
||||
|
||||
|
||||
@@ -61,16 +61,16 @@ const i18n = createI18n({
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
loading: 'Loading'
|
||||
install: 'Install',
|
||||
loading: 'Loading',
|
||||
search: 'Search'
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate node on canvas',
|
||||
missingNodePacks: {
|
||||
unknownPack: 'Unknown pack',
|
||||
installNodePack: 'Install node pack',
|
||||
installing: 'Installing...',
|
||||
installed: 'Installed',
|
||||
searchInManager: 'Search in Node Manager',
|
||||
viewInManager: 'View in Manager',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand'
|
||||
@@ -100,7 +100,6 @@ function renderRow(
|
||||
props: Partial<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
@@ -110,7 +109,6 @@ function renderRow(
|
||||
props: {
|
||||
group: makeGroup(),
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
onLocateNode,
|
||||
onOpenManagerInfo,
|
||||
...props
|
||||
@@ -118,7 +116,6 @@ function renderRow(
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
stubs: {
|
||||
TransitionCollapse: { template: '<div><slot /></div>' },
|
||||
DotSpinner: {
|
||||
template: '<span role="status" aria-label="loading" />'
|
||||
}
|
||||
@@ -156,9 +153,22 @@ describe('MissingPackGroupRow', () => {
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render header locate while pack metadata is resolving', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
isResolving: true,
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Locate node on canvas' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node count', () => {
|
||||
renderRow()
|
||||
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders count of 5 for 5 nodeTypes', () => {
|
||||
@@ -171,38 +181,29 @@ describe('MissingPackGroupRow', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Expand / Collapse', () => {
|
||||
it('starts collapsed', () => {
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands when chevron is clicked', async () => {
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses when chevron is clicked again', async () => {
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type List', () => {
|
||||
async function expand(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
}
|
||||
it('hides multiple nodeTypes behind the expand control by default', () => {
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all nodeTypes when expanded', async () => {
|
||||
it('shows unknown pack nodeTypes by default', () => {
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Collapse' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all nodeTypes after expanding', async () => {
|
||||
const { user } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
@@ -212,40 +213,87 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
expect(screen.getByText('NodeA')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeB')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows nodeId badge when showNodeIdBadge is true', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
it('hides multiple nodeTypes again after collapsing', async () => {
|
||||
const { user } = renderRow()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides nodeId badge when showNodeIdBadge is false', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: false })
|
||||
await expand(user)
|
||||
expect(screen.queryByText('#10')).not.toBeInTheDocument()
|
||||
it('hides a single nodeType without an expand control', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Expand' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits locateNode when Locate button is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
|
||||
const { user, onLocateNode } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'my-pack' }))
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('100')
|
||||
})
|
||||
|
||||
it('moves locate to the header when there is one nodeType', async () => {
|
||||
const { user, onLocateNode } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Locate node on canvas' })
|
||||
)
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('100')
|
||||
})
|
||||
|
||||
it('emits locateNode when expanded child Locate button is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
|
||||
)
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not show Locate for nodeType without nodeId', async () => {
|
||||
const { user } = renderRow({
|
||||
it('emits locateNode when node label is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
await user.click(screen.getByRole('button', { name: 'MissingA' }))
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not show Locate for nodeType without nodeId', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Locate node on canvas' })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -253,7 +301,6 @@ describe('MissingPackGroupRow', () => {
|
||||
|
||||
it('handles mixed nodeTypes with and without nodeId', async () => {
|
||||
const { user } = renderRow({
|
||||
showNodeIdBadge: true,
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
{ type: 'WithId', nodeId: '100', isReplaceable: false },
|
||||
@@ -261,7 +308,7 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('WithId')).toBeInTheDocument()
|
||||
expect(screen.getByText('WithoutId')).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -274,21 +321,25 @@ describe('MissingPackGroupRow', () => {
|
||||
it('hides install UI when shouldShowManagerButtons is false', () => {
|
||||
mockShouldShowManagerButtons.value = false
|
||||
renderRow()
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides install UI when packId is null', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
|
||||
it('shows Search when packId exists but pack not in registry', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = []
|
||||
renderRow()
|
||||
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Installed" state when pack is installed', () => {
|
||||
@@ -312,7 +363,9 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
renderRow()
|
||||
expect(screen.getByText('Install node pack')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Install' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls installAllPacks when Install button is clicked', async () => {
|
||||
@@ -320,9 +373,7 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const { user } = renderRow()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Install node pack/ })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Install' }))
|
||||
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -369,7 +420,7 @@ describe('MissingPackGroupRow', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty nodeTypes array', () => {
|
||||
renderRow({ group: makeGroup({ nodeTypes: [] }) })
|
||||
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,187 +1,221 @@
|
||||
<template>
|
||||
<div class="mb-2 flex w-full flex-col">
|
||||
<!-- Pack header row: pack name + info + chevron -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<!-- Warning icon for unknown packs -->
|
||||
<i
|
||||
v-if="group.packId === null && !group.isResolving"
|
||||
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:class="
|
||||
group.packId === null && !group.isResolving
|
||||
? 'text-warning-background'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<Button
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="missing-node-pack-expand"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
{ 'rotate-180': expanded }
|
||||
)
|
||||
"
|
||||
size="unset"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Sub-labels: individual node instances, each with their own Locate button -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
|
||||
>
|
||||
<div
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
showNodeIdBadge &&
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null
|
||||
<i
|
||||
v-if="isUnknownPack"
|
||||
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 items-center gap-2.5">
|
||||
<button
|
||||
v-if="hasMultipleNodeTypes && !group.isResolving"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
isUnknownPack
|
||||
? 'text-warning-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
:aria-expanded="expanded"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
#{{ nodeType.nodeId }}
|
||||
{{ packDisplayName }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="primaryLocatableNodeType"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
isUnknownPack
|
||||
? 'text-warning-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
{{ packDisplayName }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ packDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getLabel(nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3" />
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
|
||||
<div
|
||||
v-if="
|
||||
shouldShowManagerButtons &&
|
||||
group.packId !== null &&
|
||||
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
|
||||
"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
:disabled="
|
||||
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
|
||||
"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
|
||||
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
|
||||
/>
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: comfyManagerStore.isPackInstalled(group.packId)
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('rightSidePanel.missingNodePacks.installNodePack')
|
||||
}}
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Registry still loading: packId known but result not yet available -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
</span>
|
||||
<div v-if="showInstallAction" class="ml-auto shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<span class="text-foreground min-w-0 truncate">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: isPackInstalled
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('g.install')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search in Manager: fetch done but pack not found in registry -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="text-foreground min-w-0 truncate">
|
||||
{{ t('g.search') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
|
||||
</span>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
<ul
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
'text-muted-foreground hover:text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
@@ -193,10 +227,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
|
||||
const { group, showInfoButton } = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -205,6 +238,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
@@ -219,17 +256,73 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
|
||||
nodePack.value ? [nodePack.value] : []
|
||||
)
|
||||
|
||||
const isUnknownPack = computed(
|
||||
() => group.packId === null && !group.isResolving
|
||||
)
|
||||
|
||||
const packDisplayName = computed(() => {
|
||||
if (group.packId === null) {
|
||||
return t('rightSidePanel.missingNodePacks.unknownPack')
|
||||
}
|
||||
return nodePack.value?.name ?? group.packId
|
||||
})
|
||||
|
||||
const isPackInstalled = computed(
|
||||
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
|
||||
)
|
||||
|
||||
const showInstallAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
(nodePack.value !== null || isPackInstalled.value)
|
||||
)
|
||||
|
||||
const showLoadingAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
!showInstallAction.value &&
|
||||
isLoading.value
|
||||
)
|
||||
|
||||
const showSearchAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
!showInstallAction.value &&
|
||||
!showLoadingAction.value
|
||||
)
|
||||
|
||||
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
|
||||
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
|
||||
const expanded = computed(
|
||||
() =>
|
||||
expandedOverride.value ??
|
||||
(isUnknownPack.value && hasMultipleNodeTypes.value)
|
||||
)
|
||||
const showNodeTypeList = computed(
|
||||
() =>
|
||||
(isUnknownPack.value && group.nodeTypes.length === 1) ||
|
||||
(hasMultipleNodeTypes.value && expanded.value)
|
||||
)
|
||||
const primaryLocatableNodeType = computed(() => {
|
||||
if (group.isResolving) return null
|
||||
if (isUnknownPack.value) return null
|
||||
if (group.nodeTypes.length !== 1) return null
|
||||
const [nodeType] = group.nodeTypes
|
||||
return isLocatableNodeType(nodeType) ? nodeType : null
|
||||
})
|
||||
|
||||
function handlePackInstallClick() {
|
||||
if (!group.packId) return
|
||||
if (!comfyManagerStore.isPackInstalled(group.packId)) {
|
||||
if (!isPackInstalled.value) {
|
||||
void installAllPacks()
|
||||
}
|
||||
}
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpand() {
|
||||
expanded.value = !expanded.value
|
||||
expandedOverride.value = !expanded.value
|
||||
}
|
||||
|
||||
function getKey(nodeType: MissingNodeType): string {
|
||||
@@ -241,10 +334,14 @@ function getLabel(nodeType: MissingNodeType): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function isLocatableNodeType(
|
||||
nodeType: MissingNodeType
|
||||
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
|
||||
return typeof nodeType !== 'string' && nodeType.nodeId != null
|
||||
}
|
||||
|
||||
function handleLocateNode(nodeType: MissingNodeType) {
|
||||
if (typeof nodeType === 'string') return
|
||||
if (nodeType.nodeId != null) {
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
if (!isLocatableNodeType(nodeType)) return
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -93,16 +93,7 @@ describe('TabErrors.vue', () => {
|
||||
refreshing: 'Refreshing missing models.'
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaTitle: 'Missing Inputs',
|
||||
image: 'Images',
|
||||
uploadFile: 'Upload {type}',
|
||||
useFromLibrary: 'Use from Library',
|
||||
confirmSelection: 'Confirm selection',
|
||||
locateNode: 'Locate node',
|
||||
expandNodes: 'Show referencing nodes',
|
||||
collapseNodes: 'Hide referencing nodes',
|
||||
cancelSelection: 'Cancel selection',
|
||||
or: 'OR'
|
||||
missingMediaTitle: 'Missing Inputs'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,6 +459,50 @@ describe('TabErrors.vue', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders one missing media item per referencing node and locates the selected node', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
const titles: Record<string, string> = {
|
||||
'3': 'First Loader',
|
||||
'4': 'Second Loader'
|
||||
}
|
||||
return {
|
||||
title: titles[String(nodeId)] ?? ''
|
||||
} as ReturnType<typeof getNodeByExecutionId>
|
||||
})
|
||||
|
||||
const { user } = renderComponent({
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [
|
||||
{
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'shared.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '4',
|
||||
nodeType: 'PreviewImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'shared.png',
|
||||
isMissing: true
|
||||
}
|
||||
] satisfies MissingMediaCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
)
|
||||
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
@@ -256,7 +255,6 @@
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
@@ -131,6 +131,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
|
||||
function makeMissingNodeType(
|
||||
type: string,
|
||||
@@ -178,6 +179,24 @@ function makeModel(
|
||||
}
|
||||
}
|
||||
|
||||
function makeMedia(
|
||||
name: string,
|
||||
opts: {
|
||||
nodeId: string | number
|
||||
nodeType?: string
|
||||
widgetName?: string
|
||||
}
|
||||
): MissingMediaCandidate {
|
||||
return {
|
||||
name,
|
||||
nodeId: opts.nodeId,
|
||||
nodeType: opts.nodeType ?? 'LoadImage',
|
||||
widgetName: opts.widgetName ?? 'image',
|
||||
mediaType: 'image',
|
||||
isMissing: true
|
||||
}
|
||||
}
|
||||
|
||||
function createErrorGroups() {
|
||||
const store = useExecutionErrorStore()
|
||||
const searchQuery = ref('')
|
||||
@@ -1060,6 +1079,27 @@ describe('useErrorGroups', () => {
|
||||
groups.missingModelGroups.value
|
||||
)
|
||||
})
|
||||
|
||||
it('counts missing media by affected node rows, not grouped filenames', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingMedia([
|
||||
makeMedia('shared.png', { nodeId: '1', nodeType: 'LoadImage' }),
|
||||
makeMedia('shared.png', { nodeId: '2', nodeType: 'PreviewImage' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(store.totalErrorCount).toBe(2)
|
||||
expect(groups.missingMediaGroups.value).toHaveLength(1)
|
||||
expect(groups.missingMediaGroups.value[0].items).toHaveLength(1)
|
||||
expect(
|
||||
groups.missingMediaGroups.value[0].items[0].referencingNodes
|
||||
).toHaveLength(2)
|
||||
|
||||
const missingMediaGroup = groups.allErrorGroups.value.find(
|
||||
(group) => group.type === 'missing_media'
|
||||
)
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabErrorGroups', () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import {
|
||||
resolveMissingErrorMessage,
|
||||
resolveRunErrorMessage
|
||||
@@ -690,10 +691,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function buildMissingMediaGroups(): ErrorGroup[] {
|
||||
if (!missingMediaGroups.value.length) return []
|
||||
const totalItems = missingMediaGroups.value.reduce(
|
||||
(count, group) => count + group.items.length,
|
||||
0
|
||||
)
|
||||
const totalRows = countMissingMediaReferences(missingMediaGroups.value)
|
||||
return [
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
@@ -702,7 +700,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups: missingMediaGroups.value,
|
||||
count: totalItems,
|
||||
count: totalRows,
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
@@ -806,9 +804,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingMediaGroups.value.length) return []
|
||||
const totalItems = filteredMissingMediaGroups.value.reduce(
|
||||
(count, group) => count + group.items.length,
|
||||
0
|
||||
const totalRows = countMissingMediaReferences(
|
||||
filteredMissingMediaGroups.value
|
||||
)
|
||||
return [
|
||||
{
|
||||
@@ -818,7 +815,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups: filteredMissingMediaGroups.value,
|
||||
count: totalItems,
|
||||
count: totalRows,
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
captureRoot,
|
||||
getRoot,
|
||||
resetRoot,
|
||||
mockAddNodeOnGraph,
|
||||
mockStartDrag,
|
||||
mockGetNodeProvider,
|
||||
mockToggleNodeOnEvent,
|
||||
mockRefreshModelFolder,
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
resetRoot: () => {
|
||||
capturedRoot = null
|
||||
},
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockStartDrag: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn(),
|
||||
mockToggleNodeOnEvent: vi.fn(),
|
||||
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -37,8 +37,8 @@ const {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ addNodeOnGraph: mockAddNodeOnGraph })
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
@@ -173,16 +173,13 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles model click and adds node to graph', async () => {
|
||||
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
|
||||
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
const mockWidget = { name: 'ckpt_name', value: '' }
|
||||
const mockGraphNode = { widgets: [mockWidget] }
|
||||
|
||||
mockGetNodeProvider.mockReturnValue({
|
||||
nodeDef: mockNodeDef,
|
||||
key: 'ckpt_name'
|
||||
})
|
||||
mockAddNodeOnGraph.mockReturnValue(mockGraphNode)
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
@@ -198,8 +195,9 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
await modelLeaf?.handleClick?.(mockEvent)
|
||||
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef)
|
||||
expect(mockWidget.value).toBe('model.safetensors')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles folder expansion on click', async () => {
|
||||
|
||||
@@ -63,10 +63,9 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { startModelLoaderDrag } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
import { ResourceState, useModelStore } from '@/stores/modelStore'
|
||||
@@ -156,15 +155,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
)
|
||||
if (widget) {
|
||||
widget.value = model.file_name
|
||||
}
|
||||
startModelLoaderDrag(provider, model.file_name)
|
||||
}
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
|
||||
@@ -31,11 +31,8 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
isDragging: { value: false },
|
||||
draggedNode: { value: null },
|
||||
cursorPosition: { value: { x: 0, y: 0 } },
|
||||
startDrag: vi.fn(),
|
||||
cancelDrag: vi.fn(),
|
||||
setupGlobalListeners: vi.fn(),
|
||||
cleanupGlobalListeners: vi.fn()
|
||||
cancelDrag: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeDragPreview />
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="hasNoMatches"
|
||||
@@ -215,7 +214,6 @@ import type {
|
||||
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
|
||||
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
|
||||
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
|
||||
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
|
||||
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDragging && draggedNode && showPreview"
|
||||
class="pointer-events-none fixed z-10000"
|
||||
:style="{
|
||||
left: `${previewPosition.x + 12}px`,
|
||||
top: `${previewPosition.y + 12}px`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview :node-def="draggedNode" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
} = useNodeDragToCanvas()
|
||||
|
||||
const nativeDragPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const previewPosition = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value
|
||||
}
|
||||
return cursorPosition.value
|
||||
})
|
||||
|
||||
const showPreview = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
function handleDrag(e: DragEvent) {
|
||||
if (e.clientX === 0 && e.clientY === 0) return
|
||||
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
nativeDragPosition.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupGlobalListeners()
|
||||
document.addEventListener('drag', handleDrag)
|
||||
document.addEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupGlobalListeners()
|
||||
document.removeEventListener('drag', handleDrag)
|
||||
document.removeEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
</script>
|
||||
73
src/composables/node/startModelNodeDragFromAsset.test.ts
Normal file
73
src/composables/node/startModelNodeDragFromAsset.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
|
||||
const { mockStartDrag, mockGetNodeProvider } = vi.hoisted(() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
|
||||
}))
|
||||
|
||||
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-123',
|
||||
name: 'sd_xl_base_1.0.safetensors',
|
||||
size: 1024,
|
||||
created_at: '2025-10-01T00:00:00Z',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: { filename: 'sd_xl_base_1.0.safetensors' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('startModelNodeDragFromAsset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('starts a ghost drag for the resolved node carrying the widget value', () => {
|
||||
const nodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
|
||||
|
||||
const error = startModelNodeDragFromAsset(createAsset())
|
||||
|
||||
expect(error).toBeUndefined()
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' }
|
||||
})
|
||||
})
|
||||
|
||||
it('carries no widget value when the provider has no key', () => {
|
||||
const nodeDef = { name: 'FL_ChatterboxVC' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: '' })
|
||||
|
||||
startModelNodeDragFromAsset(
|
||||
createAsset({
|
||||
tags: ['models', 'chatterbox/chatterbox_vc'],
|
||||
user_metadata: { filename: 'chatterbox_vc_model.pt' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the resolution error and does not start a drag for an invalid asset', () => {
|
||||
mockGetNodeProvider.mockReturnValue(null)
|
||||
|
||||
const error = startModelNodeDragFromAsset(createAsset())
|
||||
|
||||
expect(error?.code).toBe('NO_PROVIDER')
|
||||
expect(mockStartDrag).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
35
src/composables/node/startModelNodeDragFromAsset.ts
Normal file
35
src/composables/node/startModelNodeDragFromAsset.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
import type { ResolveModelNodeError } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
|
||||
|
||||
/**
|
||||
* Arms a ghost drag for a model loader node. Providers with no widget key
|
||||
* (auto-load nodes) start the drag without widget values.
|
||||
*/
|
||||
export function startModelLoaderDrag(
|
||||
provider: ModelNodeProvider,
|
||||
filename: string
|
||||
) {
|
||||
const widgetValues = provider.key ? { [provider.key]: filename } : undefined
|
||||
useNodeDragToCanvas().startDrag(provider.nodeDef, { widgetValues })
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a ghost drag for the model loader node described by an asset. The
|
||||
* node is created where the user next clicks the canvas, with the asset's
|
||||
* filename written into the loader widget.
|
||||
*
|
||||
* @returns the resolution error when the asset cannot be mapped to a node,
|
||||
* otherwise `undefined`.
|
||||
*/
|
||||
export function startModelNodeDragFromAsset(
|
||||
asset: AssetItem
|
||||
): ResolveModelNodeError | undefined {
|
||||
const resolved = resolveModelNodeFromAsset(asset)
|
||||
if (!resolved.success) return resolved.error
|
||||
|
||||
const { provider, filename } = resolved.value
|
||||
startModelLoaderDrag(provider, filename)
|
||||
}
|
||||
@@ -7,7 +7,8 @@ const {
|
||||
mockAddNodeOnGraph,
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas
|
||||
mockCanvas,
|
||||
mockToastAdd
|
||||
} = vi.hoisted(() => {
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
const mockSelectItems = vi.fn()
|
||||
@@ -15,6 +16,7 @@ const {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockToastAdd: vi.fn(),
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
@@ -37,6 +39,12 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
describe('useNodeDragToCanvas', () => {
|
||||
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
|
||||
|
||||
@@ -54,8 +62,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const { cleanupGlobalListeners } = useNodeDragToCanvas()
|
||||
cleanupGlobalListeners()
|
||||
const { cancelDrag } = useNodeDragToCanvas()
|
||||
cancelDrag()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -71,22 +79,6 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(true)
|
||||
expect(draggedNode.value).toBe(mockNodeDef)
|
||||
})
|
||||
|
||||
it('should set dragMode to click by default', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
|
||||
it('should set dragMode to native when specified', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
expect(dragMode.value).toBe('native')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelDrag', () => {
|
||||
@@ -102,30 +94,15 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(draggedNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should reset dragMode to click', () => {
|
||||
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
expect(dragMode.value).toBe('native')
|
||||
|
||||
cancelDrag()
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupGlobalListeners', () => {
|
||||
it('should add event listeners to document', () => {
|
||||
describe('drag listener lifecycle', () => {
|
||||
it('should attach document listeners on startDrag', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointermove',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
@@ -142,35 +119,53 @@ describe('useNodeDragToCanvas', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should only setup listeners once', () => {
|
||||
it('should not attach drag listeners until a drag starts', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'keydown',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should detach document listeners on cancelDrag', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
cancelDrag()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should only attach listeners once across re-arms', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
const callCount = addEventListenerSpy.mock.calls.length
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursorPosition', () => {
|
||||
it('should update on pointermove', () => {
|
||||
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
|
||||
const pointerEvent = new PointerEvent('pointermove', {
|
||||
clientX: 100,
|
||||
clientY: 200
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('endDrag behavior', () => {
|
||||
it('should add node when pointer is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
@@ -181,9 +176,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
@@ -206,10 +199,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
bottom: 500
|
||||
})
|
||||
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
@@ -224,10 +214,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should cancel drag on Escape key', () => {
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(isDragging.value).toBe(true)
|
||||
@@ -239,10 +226,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not cancel drag on other keys', () => {
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
|
||||
@@ -262,8 +246,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
const placedNode = { id: 1 }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
@@ -277,6 +260,97 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
})
|
||||
|
||||
it('should apply the requested widget values to the placed node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const widget = { name: 'ckpt_name', value: '' }
|
||||
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(widget.value).toBe('model.safetensors')
|
||||
})
|
||||
|
||||
it('should still place the node when a requested widget is missing', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const placedNode = { id: 1, widgets: [] }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ckpt_name')
|
||||
)
|
||||
})
|
||||
|
||||
it('should show an error toast when the graph fails to add the node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'assetBrowser.failedToCreateNode'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not call selectItems when graph returns no node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
@@ -286,9 +360,9 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
@@ -311,11 +385,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
@@ -341,7 +412,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
@@ -359,7 +430,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(600, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -377,7 +448,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'click')
|
||||
startDrag(mockNodeDef)
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -392,14 +463,12 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging, dragMode } =
|
||||
useNodeDragToCanvas()
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -426,31 +495,29 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should stop propagation when in click-drag mode over canvas', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when not dragging', () => {
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
it('should not stop propagation once the drag is cancelled', () => {
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
cancelDrag()
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation in native drag mode', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when pointer is outside canvas', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
|
||||
@@ -477,10 +544,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
}
|
||||
|
||||
it('should prefer tracked drag position over dragend coordinates', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
fireDrag(250, 250)
|
||||
// dragend supplies a bad position (the Firefox bug); the tracked one
|
||||
@@ -494,10 +559,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should ignore drag events with (0, 0)', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
fireDrag(250, 250)
|
||||
fireDrag(0, 0)
|
||||
@@ -510,10 +573,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should fall back to dragend coordinates when no drag fired', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
@@ -523,32 +584,14 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore dragover events fired before startDrag', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
fireDrag(250, 250)
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
|
||||
clientX: 300,
|
||||
clientY: 300
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear tracked position between drags', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
fireDrag(250, 250)
|
||||
handleNativeDrop(1505, 102)
|
||||
|
||||
// Second drag - no drag events, so we should fall back to args.
|
||||
startDrag(mockNodeDef, 'native')
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
type DragMode = 'click' | 'native'
|
||||
type WidgetValues = Record<string, string>
|
||||
type Position = { x: number; y: number }
|
||||
|
||||
interface StartDragOptions {
|
||||
mode?: DragMode
|
||||
widgetValues?: WidgetValues
|
||||
}
|
||||
|
||||
const isDragging = ref(false)
|
||||
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
|
||||
const cursorPosition = ref({ x: 0, y: 0 })
|
||||
const dragMode = ref<DragMode>('click')
|
||||
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
|
||||
const lastNativeDragPosition = shallowRef<Position>()
|
||||
const pendingWidgetValues = shallowRef<WidgetValues>()
|
||||
let listenersSetup = false
|
||||
|
||||
function updatePosition(e: PointerEvent) {
|
||||
cursorPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
// Firefox dragend can report stale clientX/Y and `drag` can fire with
|
||||
// (0, 0). dragover on the target reliably reports real client coords.
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
|
||||
@@ -27,11 +33,15 @@ function trackNativeDragPosition(e: DragEvent) {
|
||||
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
lastNativeDragPosition.value = undefined
|
||||
function applyWidgetValues(node: LGraphNode, values: WidgetValues) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const widget = node.widgets?.find((w) => w.name === name)
|
||||
if (!widget) {
|
||||
console.error(`Widget ${name} not found on node ${node.type}`)
|
||||
continue
|
||||
}
|
||||
widget.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
@@ -62,7 +72,19 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
const node = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
if (node) canvas.selectItems([node])
|
||||
if (!node) {
|
||||
console.error(`Failed to add node to graph: ${nodeDef.name}`)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('assetBrowser.failedToCreateNode')
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (pendingWidgetValues.value)
|
||||
applyWidgetValues(node, pendingWidgetValues.value)
|
||||
canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -92,7 +114,6 @@ function setupGlobalListeners() {
|
||||
if (listenersSetup) return
|
||||
listenersSetup = true
|
||||
|
||||
document.addEventListener('pointermove', updatePosition)
|
||||
document.addEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.addEventListener('pointerup', endDrag, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
@@ -103,22 +124,31 @@ function cleanupGlobalListeners() {
|
||||
if (!listenersSetup) return
|
||||
listenersSetup = false
|
||||
|
||||
document.removeEventListener('pointermove', updatePosition)
|
||||
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.removeEventListener('pointerup', endDrag, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('dragover', trackNativeDragPosition)
|
||||
}
|
||||
|
||||
if (isDragging.value && dragMode.value === 'click') {
|
||||
cancelDrag()
|
||||
}
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
lastNativeDragPosition.value = undefined
|
||||
pendingWidgetValues.value = undefined
|
||||
cleanupGlobalListeners()
|
||||
}
|
||||
|
||||
export function useNodeDragToCanvas() {
|
||||
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
|
||||
function startDrag(
|
||||
nodeDef: ComfyNodeDefImpl,
|
||||
{ mode = 'click', widgetValues }: StartDragOptions = {}
|
||||
) {
|
||||
isDragging.value = true
|
||||
draggedNode.value = nodeDef
|
||||
dragMode.value = mode
|
||||
pendingWidgetValues.value = widgetValues
|
||||
setupGlobalListeners()
|
||||
}
|
||||
|
||||
function handleNativeDrop(clientX: number, clientY: number) {
|
||||
@@ -134,12 +164,9 @@ export function useNodeDragToCanvas() {
|
||||
return {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
pendingWidgetValues,
|
||||
startDrag,
|
||||
cancelDrag,
|
||||
handleNativeDrop,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
handleNativeDrop
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,9 @@ describe('useNodePreviewAndDrag', () => {
|
||||
|
||||
expect(result.isDragging.value).toBe(true)
|
||||
expect(result.isHovered.value).toBe(false)
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, 'native')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
|
||||
mode: 'native'
|
||||
})
|
||||
expect(mockDataTransfer.effectAllowed).toBe('copy')
|
||||
expect(mockDataTransfer.setData).toHaveBeenCalledWith(
|
||||
'application/x-comfy-node',
|
||||
|
||||
@@ -112,7 +112,7 @@ export function useNodePreviewAndDrag(
|
||||
isDragging.value = true
|
||||
isHovered.value = false
|
||||
|
||||
startDrag(nodeDef.value, 'native')
|
||||
startDrag(nodeDef.value, { mode: 'native' })
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
|
||||
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
|
||||
import {
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -1305,14 +1305,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
assetType: 'models',
|
||||
title: t('sideToolbar.modelLibrary'),
|
||||
onAssetSelected: (asset) => {
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
if (!result.success) {
|
||||
const error = startModelNodeDragFromAsset(asset)
|
||||
if (error) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('assetBrowser.failedToCreateNode')
|
||||
})
|
||||
console.error('Node creation failed:', result.error)
|
||||
console.error('Node creation failed:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user