mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-10 16:29:26 +00:00
Compare commits
7 Commits
v1.46.11
...
uy/node-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed4f7db7f4 | ||
|
|
39157f2375 | ||
|
|
47118ef64f | ||
|
|
f110af79f7 | ||
|
|
8972d27689 | ||
|
|
72d1261983 | ||
|
|
1b90696459 |
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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) }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4634,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',
|
||||
@@ -4677,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>
|
||||
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>
|
||||
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>
|
||||
@@ -69,11 +69,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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -256,7 +256,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
|
||||
})
|
||||
}
|
||||
|
||||
27
src/composables/useImageQuiet.ts
Normal file
27
src/composables/useImageQuiet.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useImage } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* `useImage()` that handles load failures quietly.
|
||||
*
|
||||
* `useImage()` already surfaces failures via its returned `error` ref (callers
|
||||
* render a fallback). By default vueuse ALSO forwards the error to
|
||||
* `globalThis.reportError`, which our error monitoring (Datadog RUM) captures as
|
||||
* an unhandled error for every broken image — 404'd thumbnails, expired share
|
||||
* links, in-app browsers that re-fetch in a loop. Broken images are expected,
|
||||
* not bugs, so handle the failure here instead of letting it surface globally.
|
||||
* The returned `error` ref behaviour is unchanged.
|
||||
*
|
||||
* `asyncStateOptions` is forwarded to `useImage`, so callers can still tune the
|
||||
* other `useAsyncState` fields; only `onError` is fixed to the quiet default.
|
||||
*/
|
||||
export function useImageQuiet(
|
||||
options: Parameters<typeof useImage>[0],
|
||||
asyncStateOptions?: Parameters<typeof useImage>[1]
|
||||
) {
|
||||
return useImage(options, {
|
||||
...asyncStateOptions,
|
||||
onError: () => {
|
||||
// Surfaced via the returned `error` ref; see the doc comment above.
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3671,21 +3671,7 @@
|
||||
"refreshFailed": "Failed to refresh missing models. Please try again."
|
||||
},
|
||||
"missingMedia": {
|
||||
"missingMediaTitle": "Missing Inputs",
|
||||
"image": "Images",
|
||||
"video": "Videos",
|
||||
"audio": "Audio",
|
||||
"locateNode": "Locate node",
|
||||
"expandNodes": "Show referencing nodes",
|
||||
"collapseNodes": "Hide referencing nodes",
|
||||
"uploadFile": "Upload {type}",
|
||||
"uploading": "Uploading...",
|
||||
"uploaded": "Uploaded",
|
||||
"selectedFromLibrary": "Selected from library",
|
||||
"useFromLibrary": "Use from Library",
|
||||
"confirmSelection": "Confirm selection",
|
||||
"cancelSelection": "Cancel selection",
|
||||
"or": "OR"
|
||||
"missingMediaTitle": "Missing Inputs"
|
||||
}
|
||||
},
|
||||
"errorOverlay": {
|
||||
@@ -3736,6 +3722,7 @@
|
||||
},
|
||||
"missing_media": {
|
||||
"displayMessage": "A required media input has no file selected.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitleOne": "Media input missing",
|
||||
"toastTitleMany": "Missing media inputs",
|
||||
"toastMessageWithNode": "{nodeName} is missing a required media file.",
|
||||
|
||||
@@ -128,10 +128,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed, ref, toValue, useId, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
@@ -190,7 +190,7 @@ const tooltipDelay = computed<number>(() =>
|
||||
settingStore.get('LiteGraph.Node.TooltipDelay')
|
||||
)
|
||||
|
||||
const { isLoading, error } = useImage({
|
||||
const { isLoading, error } = useImageQuiet({
|
||||
src: asset.preview_url ?? '',
|
||||
alt: displayName.value
|
||||
})
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage, whenever } from '@vueuse/core'
|
||||
import { whenever } from '@vueuse/core'
|
||||
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
|
||||
@@ -34,7 +35,7 @@ const emit = defineEmits<{
|
||||
view: []
|
||||
}>()
|
||||
|
||||
const { state, error, isReady } = useImage({
|
||||
const { state, error, isReady } = useImageQuiet({
|
||||
src: asset.src ?? '',
|
||||
alt: getAssetDisplayName(asset)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
resolveMissingErrorMessage,
|
||||
resolveMissingMediaItemLabel,
|
||||
resolveRunErrorMessage
|
||||
} from './errorMessageResolver'
|
||||
import type { NodeValidationError } from './types'
|
||||
@@ -1579,6 +1580,32 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
source: { nodeType: 'LoadImage', widgetName: 'image' },
|
||||
displayItemLabel: 'Load Image - image'
|
||||
},
|
||||
{
|
||||
source: {
|
||||
nodeDisplayName: 'Custom Loader',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image'
|
||||
},
|
||||
displayItemLabel: 'Custom Loader - image'
|
||||
},
|
||||
{
|
||||
source: { nodeType: '', widgetName: '' },
|
||||
displayItemLabel: 'This node - unknown input'
|
||||
}
|
||||
] as const)(
|
||||
'resolves missing media item labels from $source',
|
||||
({ source, displayItemLabel }) => {
|
||||
expect(resolveMissingMediaItemLabel(source)).toEqual({
|
||||
displayItemLabel
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
[
|
||||
'image',
|
||||
@@ -1649,6 +1676,44 @@ describe('errorMessageResolver', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('summarizes a shared missing media file by affected node references', () => {
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups: [
|
||||
{
|
||||
mediaType: 'image',
|
||||
items: [
|
||||
{
|
||||
name: 'shared.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'shared.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [
|
||||
{ nodeId: '1', widgetName: 'image' },
|
||||
{ nodeId: '2', widgetName: 'image' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
count: 2,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Missing Inputs (2)',
|
||||
toastTitle: 'Missing media inputs',
|
||||
toastMessage:
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes multiple missing model and media items', () => {
|
||||
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')
|
||||
|
||||
|
||||
@@ -5,13 +5,15 @@ import type {
|
||||
} from './types'
|
||||
|
||||
import { resolveExecutionErrorMessage } from './executionErrorResolver'
|
||||
import { resolveMissingErrorMessage } from './missingErrorResolver'
|
||||
import { resolvePromptErrorMessage } from './promptErrorResolver'
|
||||
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
|
||||
|
||||
// Public facade for error catalog resolution. Source-specific resolver modules
|
||||
// own the actual matching/copy rules so this file stays as the routing boundary.
|
||||
export { resolveMissingErrorMessage }
|
||||
export {
|
||||
resolveMissingErrorMessage,
|
||||
resolveMissingMediaItemLabel
|
||||
} from './missingErrorResolver'
|
||||
|
||||
export function resolveRunErrorMessage(
|
||||
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>
|
||||
|
||||
@@ -2,7 +2,8 @@ import type {
|
||||
MissingErrorMessageSource,
|
||||
ResolvedMissingErrorMessage
|
||||
} from './types'
|
||||
import { translateCatalogMessage } from './catalogI18n'
|
||||
import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
function formatCountTitle(title: string, count: number): string {
|
||||
@@ -255,6 +256,12 @@ type MissingMediaSource = Extract<
|
||||
{ kind: 'missing_media' }
|
||||
>
|
||||
|
||||
interface MissingMediaItemLabelSource {
|
||||
nodeDisplayName?: string
|
||||
nodeType?: string
|
||||
widgetName?: string
|
||||
}
|
||||
|
||||
function getMissingMediaItems(source: MissingMediaSource) {
|
||||
return source.groups.flatMap((group) => group.items)
|
||||
}
|
||||
@@ -272,9 +279,29 @@ function resolveMissingMediaDisplayMessage(): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveMissingMediaItemLabel(
|
||||
source: MissingMediaItemLabelSource
|
||||
): { displayItemLabel: string } {
|
||||
const nodeName = normalizeNodeName(
|
||||
source.nodeDisplayName ||
|
||||
formatNodeTypeName(source.nodeType ?? '') ||
|
||||
undefined
|
||||
)
|
||||
const inputName =
|
||||
source.widgetName?.trim() ||
|
||||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
|
||||
|
||||
return {
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.itemLabel',
|
||||
'{nodeName} - {inputName}',
|
||||
{ nodeName, inputName }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
|
||||
const items = getMissingMediaItems(source)
|
||||
if (items.length !== 1) {
|
||||
if (countMissingMediaReferences(source.groups) !== 1) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastTitleMany',
|
||||
'Missing media inputs'
|
||||
@@ -290,7 +317,7 @@ function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
|
||||
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
|
||||
const items = getMissingMediaItems(source)
|
||||
const [firstItem] = items
|
||||
if (!firstItem || items.length !== 1) {
|
||||
if (!firstItem || countMissingMediaReferences(source.groups) !== 1) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastMessageMany',
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
|
||||
@@ -1,50 +1,61 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<div
|
||||
v-for="group in missingMediaGroups"
|
||||
:key="group.mediaType"
|
||||
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
|
||||
<TransitionGroup
|
||||
tag="ul"
|
||||
name="list-scale"
|
||||
class="relative m-0 list-none space-y-1 p-0"
|
||||
>
|
||||
<!-- Media type header -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium text-destructive-background-hover"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="MEDIA_TYPE_ICONS[group.mediaType]"
|
||||
class="mr-1 size-3.5 align-text-bottom"
|
||||
/>
|
||||
{{ t(`rightSidePanel.missingMedia.${group.mediaType}`) }}
|
||||
({{ group.items.length }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Media file rows -->
|
||||
<div class="flex flex-col gap-1 overflow-hidden pl-2">
|
||||
<MissingMediaRow
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
:item="item"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<li
|
||||
v-for="item in missingMediaItems"
|
||||
:key="item.key"
|
||||
data-testid="missing-media-row"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
@click="emit('locateNode', item.nodeId)"
|
||||
>
|
||||
{{ item.displayItemLabel }}
|
||||
</button>
|
||||
</span>
|
||||
<Button
|
||||
data-testid="missing-media-locate-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.displayItemLabel
|
||||
})
|
||||
"
|
||||
@click.stop="emit('locateNode', item.nodeId)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type {
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import MissingMediaRow from '@/platform/missingMedia/components/MissingMediaRow.vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
|
||||
import { getMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { st } from '@/i18n'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
|
||||
const { missingMediaGroups } = defineProps<{
|
||||
missingMediaGroups: MissingMediaGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -53,9 +64,60 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const MEDIA_TYPE_ICONS: Record<MediaType, string> = {
|
||||
image: 'icon-[lucide--image]',
|
||||
video: 'icon-[lucide--video]',
|
||||
audio: 'icon-[lucide--music]'
|
||||
interface MissingMediaItemEntry {
|
||||
key: string
|
||||
nodeId: string
|
||||
displayItemLabel: string
|
||||
}
|
||||
|
||||
const missingMediaItems = computed(() => {
|
||||
return getMissingMediaReferences(missingMediaGroups)
|
||||
.map(({ mediaItem, nodeRef }) => {
|
||||
const nodeId = String(nodeRef.nodeId)
|
||||
return {
|
||||
key: `${nodeId}:${nodeRef.widgetName}:${mediaItem.name}`,
|
||||
nodeId,
|
||||
displayItemLabel: getDisplayItemLabel(
|
||||
nodeId,
|
||||
nodeRef.nodeType ?? mediaItem.representative.nodeType,
|
||||
nodeRef.widgetName
|
||||
)
|
||||
}
|
||||
})
|
||||
.sort(compareMissingMediaItems)
|
||||
})
|
||||
|
||||
function getDisplayItemLabel(
|
||||
nodeId: string,
|
||||
nodeType: string,
|
||||
widgetName: string
|
||||
) {
|
||||
const nodeDisplayName = getNodeDisplayLabel(nodeId, '')
|
||||
return resolveMissingMediaItemLabel({
|
||||
nodeDisplayName,
|
||||
nodeType,
|
||||
widgetName
|
||||
}).displayItemLabel
|
||||
}
|
||||
|
||||
function compareMissingMediaItems(
|
||||
a: MissingMediaItemEntry,
|
||||
b: MissingMediaItemEntry
|
||||
) {
|
||||
return (
|
||||
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
|
||||
a.displayItemLabel.localeCompare(b.displayItemLabel)
|
||||
)
|
||||
}
|
||||
|
||||
function getNodeDisplayLabel(nodeId: string, fallback: string): string {
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return fallback
|
||||
const node = getNodeByExecutionId(graph, nodeId)
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallback,
|
||||
untitledLabel: fallback,
|
||||
st
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
|
||||
<span class="text-xs font-bold text-muted-foreground">
|
||||
{{ t('rightSidePanel.missingMedia.or') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
:disabled="options.length === 0"
|
||||
@update:model-value="handleSelect"
|
||||
>
|
||||
<SelectTrigger
|
||||
size="md"
|
||||
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
|
||||
>
|
||||
<SelectValue
|
||||
:placeholder="t('rightSidePanel.missingMedia.useFromLibrary')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent class="max-h-72">
|
||||
<template v-if="options.length > SEARCH_THRESHOLD" #prepend>
|
||||
<div class="px-1 pb-1.5">
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
v-model="filterQuery"
|
||||
type="text"
|
||||
:aria-label="t('g.searchPlaceholder', { subject: '' })"
|
||||
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('g.searchPlaceholder', { subject: '' })"
|
||||
@keydown.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SelectItem
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="mediaType === 'image'"
|
||||
:src="getPreviewUrl(option.value)"
|
||||
alt=""
|
||||
class="size-8 shrink-0 rounded-sm object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<video
|
||||
v-else-if="mediaType === 'video'"
|
||||
aria-hidden="true"
|
||||
:src="getPreviewUrl(option.value)"
|
||||
class="size-8 shrink-0 rounded-sm object-cover"
|
||||
preload="metadata"
|
||||
muted
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--music] size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ option.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
role="status"
|
||||
class="px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResultsFound') }}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import type { MediaType } from '@/platform/missingMedia/types'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const {
|
||||
options,
|
||||
showDivider = false,
|
||||
mediaType
|
||||
} = defineProps<{
|
||||
modelValue: string | undefined
|
||||
options: { name: string; value: string }[]
|
||||
showDivider?: boolean
|
||||
mediaType: MediaType
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const SEARCH_THRESHOLD = 4
|
||||
const filterQuery = ref('')
|
||||
|
||||
watch(
|
||||
() => options.length,
|
||||
(len) => {
|
||||
if (len <= SEARCH_THRESHOLD) filterQuery.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
const { results: fuseResults } = useFuse(filterQuery, () => options, {
|
||||
fuseOptions: {
|
||||
keys: ['name'],
|
||||
threshold: 0.4,
|
||||
ignoreLocation: true
|
||||
},
|
||||
matchAllWhenSearchEmpty: true
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
|
||||
|
||||
function getPreviewUrl(filename: string): string {
|
||||
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`)
|
||||
}
|
||||
|
||||
function handleSelect(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
filterQuery.value = ''
|
||||
emit('select', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,318 +0,0 @@
|
||||
<template>
|
||||
<div data-testid="missing-media-row" class="flex w-full flex-col pb-3">
|
||||
<!-- File header -->
|
||||
<div class="flex h-8 w-full items-center gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="text-foreground icon-[lucide--file] size-4 shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Single node: show node display name instead of filename -->
|
||||
<template v-if="isSingleNode">
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
#{{ item.referencingNodes[0].nodeId }}
|
||||
</span>
|
||||
<p
|
||||
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:title="singleNodeLabel"
|
||||
>
|
||||
{{ singleNodeLabel }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Multiple nodes: show filename with count -->
|
||||
<p
|
||||
v-else
|
||||
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:title="displayName"
|
||||
>
|
||||
{{ displayName }}
|
||||
({{ item.referencingNodes.length }})
|
||||
</p>
|
||||
|
||||
<!-- Confirm button (visible when pending selection exists) -->
|
||||
<Button
|
||||
data-testid="missing-media-confirm-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.confirmSelection')"
|
||||
:disabled="!isPending"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 rounded-lg transition-colors',
|
||||
isPending ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
|
||||
)
|
||||
"
|
||||
@click="confirmSelection(item.name)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="isPending ? 'text-primary' : 'text-foreground'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<!-- Locate button (single node only) -->
|
||||
<Button
|
||||
v-if="isSingleNode"
|
||||
data-testid="missing-media-locate-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateNode', String(item.referencingNodes[0].nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
|
||||
<!-- Expand button (multiple nodes only) -->
|
||||
<Button
|
||||
v-if="!isSingleNode"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingMedia.collapseNodes')
|
||||
: t('rightSidePanel.missingMedia.expandNodes')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-180'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand(item.name)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Referencing nodes (expandable) -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded && item.referencingNodes.length > 1"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
|
||||
>
|
||||
<div
|
||||
v-for="nodeRef in item.referencingNodes"
|
||||
:key="`${String(nodeRef.nodeId)}::${nodeRef.widgetName}`"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
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"
|
||||
>
|
||||
#{{ nodeRef.nodeId }}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getNodeDisplayLabel(String(nodeRef.nodeId), item.name) }}
|
||||
</p>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
||||
class="mr-1 size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateNode', String(nodeRef.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Status card (uploading, uploaded, or library select) -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="isPending || isUploading"
|
||||
data-testid="missing-media-status-card"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<i
|
||||
v-if="currentUpload?.status === 'uploading'"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--file-check] size-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<span class="text-foreground truncate text-xs/tight font-medium">
|
||||
{{ pendingDisplayName }}
|
||||
</span>
|
||||
<span class="mt-0.5 text-xs/tight text-muted-foreground">
|
||||
<template v-if="currentUpload?.status === 'uploading'">
|
||||
{{ t('rightSidePanel.missingMedia.uploading') }}
|
||||
</template>
|
||||
<template v-else-if="currentUpload?.status === 'uploaded'">
|
||||
{{ t('rightSidePanel.missingMedia.uploaded') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('rightSidePanel.missingMedia.selectedFromLibrary') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
data-testid="missing-media-cancel-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.cancelSelection')"
|
||||
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="cancelSelection(item.name)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Upload + Library (when no pending selection) -->
|
||||
<TransitionCollapse>
|
||||
<div v-if="!isPending && !isUploading" class="mt-1 flex flex-col gap-1">
|
||||
<!-- Upload dropzone -->
|
||||
<div ref="dropZoneRef" class="flex w-full flex-col py-1">
|
||||
<button
|
||||
data-testid="missing-media-upload-dropzone"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-component-node-border bg-transparent px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-base-foreground hover:text-base-foreground',
|
||||
isOverDropZone && 'border-primary text-primary'
|
||||
)
|
||||
"
|
||||
@click="openFilePicker()"
|
||||
>
|
||||
{{
|
||||
t('rightSidePanel.missingMedia.uploadFile', {
|
||||
type: extensionHint
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OR separator + Use from Library -->
|
||||
<MissingMediaLibrarySelect
|
||||
data-testid="missing-media-library-select"
|
||||
:model-value="undefined"
|
||||
:options="libraryOptions"
|
||||
:show-divider="true"
|
||||
:media-type="item.mediaType"
|
||||
@select="handleLibrarySelect(item.name, $event)"
|
||||
/>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDropZone, useFileDialog } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import MissingMediaLibrarySelect from '@/platform/missingMedia/components/MissingMediaLibrarySelect.vue'
|
||||
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import {
|
||||
useMissingMediaInteractions,
|
||||
getNodeDisplayLabel,
|
||||
getMediaDisplayName
|
||||
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
|
||||
|
||||
const { item, showNodeIdBadge } = defineProps<{
|
||||
item: MissingMediaViewModel
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const store = useMissingMediaStore()
|
||||
const { uploadState, pendingSelection } = storeToRefs(store)
|
||||
|
||||
const {
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
getAcceptType,
|
||||
getExtensionHint,
|
||||
getLibraryOptions,
|
||||
handleLibrarySelect,
|
||||
handleUpload,
|
||||
confirmSelection,
|
||||
cancelSelection,
|
||||
hasPendingSelection
|
||||
} = useMissingMediaInteractions()
|
||||
|
||||
const displayName = getMediaDisplayName(item.name)
|
||||
const isSingleNode = item.referencingNodes.length === 1
|
||||
const singleNodeLabel = isSingleNode
|
||||
? getNodeDisplayLabel(String(item.referencingNodes[0].nodeId), item.name)
|
||||
: ''
|
||||
const acceptType = getAcceptType(item.mediaType)
|
||||
const extensionHint = getExtensionHint(item.mediaType)
|
||||
|
||||
const expanded = computed(() => isExpanded(item.name))
|
||||
const matchingCandidate = computed(() => {
|
||||
const candidates = store.missingMediaCandidates
|
||||
if (!candidates?.length) return null
|
||||
return candidates.find((c) => c.name === item.name) ?? null
|
||||
})
|
||||
const libraryOptions = computed(() => {
|
||||
const candidate = matchingCandidate.value
|
||||
if (!candidate) return []
|
||||
return getLibraryOptions(candidate)
|
||||
})
|
||||
|
||||
const isPending = computed(() => hasPendingSelection(item.name))
|
||||
const isUploading = computed(
|
||||
() => uploadState.value[item.name]?.status === 'uploading'
|
||||
)
|
||||
const currentUpload = computed(() => uploadState.value[item.name])
|
||||
const pendingDisplayName = computed(() => {
|
||||
if (currentUpload.value) return currentUpload.value.fileName
|
||||
const pending = pendingSelection.value[item.name]
|
||||
return pending ? getMediaDisplayName(pending) : ''
|
||||
})
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
onDrop: (_files, event) => {
|
||||
event?.stopPropagation()
|
||||
const file = _files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file, item.name, item.mediaType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { open: openFilePicker, onChange: onFileSelected } = useFileDialog({
|
||||
accept: acceptType,
|
||||
multiple: false
|
||||
})
|
||||
onFileSelected((files) => {
|
||||
const file = files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file, item.name, item.mediaType)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,224 +0,0 @@
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type {
|
||||
MissingMediaCandidate,
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
ACCEPTED_VIDEO_TYPES
|
||||
} from '@/utils/mediaUploadUtil'
|
||||
|
||||
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
|
||||
image: ACCEPTED_IMAGE_TYPES,
|
||||
video: ACCEPTED_VIDEO_TYPES,
|
||||
audio: 'audio/*'
|
||||
}
|
||||
|
||||
function getMediaComboWidget(
|
||||
candidate: MissingMediaCandidate
|
||||
): { node: LGraphNode; widget: IComboWidget } | null {
|
||||
const graph = app.rootGraph
|
||||
if (!graph || candidate.nodeId == null) return null
|
||||
|
||||
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
|
||||
if (!node) return null
|
||||
|
||||
const widget = node.widgets?.find(
|
||||
(w) => w.name === candidate.widgetName && w.type === 'combo'
|
||||
) as IComboWidget | undefined
|
||||
if (!widget) return null
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
function resolveLibraryOptions(
|
||||
candidate: MissingMediaCandidate
|
||||
): { name: string; value: string }[] {
|
||||
const result = getMediaComboWidget(candidate)
|
||||
if (!result) return []
|
||||
|
||||
return resolveComboValues(result.widget)
|
||||
.filter((v) => v !== candidate.name)
|
||||
.map((v) => ({ name: getMediaDisplayName(v), value: v }))
|
||||
}
|
||||
|
||||
function applyValueToNodes(
|
||||
candidates: MissingMediaCandidate[],
|
||||
name: string,
|
||||
newValue: string
|
||||
) {
|
||||
const matching = candidates.filter((c) => c.name === name)
|
||||
for (const c of matching) {
|
||||
const result = getMediaComboWidget(c)
|
||||
if (!result) continue
|
||||
|
||||
addToComboValues(result.widget, newValue)
|
||||
result.widget.value = newValue
|
||||
result.widget.callback?.(newValue)
|
||||
result.node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeDisplayLabel(
|
||||
nodeId: string | number,
|
||||
fallback: string
|
||||
): string {
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return fallback
|
||||
const node = getNodeByExecutionId(graph, String(nodeId))
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallback,
|
||||
untitledLabel: fallback,
|
||||
st
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve display name for a media file.
|
||||
* Cloud widgets store asset hashes as values; this resolves them to
|
||||
* human-readable names via assetsStore.getInputName().
|
||||
*/
|
||||
export function getMediaDisplayName(name: string): string {
|
||||
if (!isCloud) return name
|
||||
return useAssetsStore().getInputName(name)
|
||||
}
|
||||
|
||||
export function useMissingMediaInteractions() {
|
||||
const store = useMissingMediaStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
function isExpanded(key: string): boolean {
|
||||
return store.expandState[key] ?? false
|
||||
}
|
||||
|
||||
function toggleExpand(key: string) {
|
||||
store.expandState[key] = !isExpanded(key)
|
||||
}
|
||||
|
||||
function getAcceptType(mediaType: MediaType): string {
|
||||
return MEDIA_ACCEPT_MAP[mediaType]
|
||||
}
|
||||
|
||||
function getExtensionHint(mediaType: MediaType): string {
|
||||
if (mediaType === 'audio') return 'audio'
|
||||
const exts = MEDIA_ACCEPT_MAP[mediaType]
|
||||
.split(',')
|
||||
.map((mime) => mime.split('/')[1])
|
||||
.join(', ')
|
||||
return `${exts}, ...`
|
||||
}
|
||||
|
||||
function getLibraryOptions(
|
||||
candidate: MissingMediaCandidate
|
||||
): { name: string; value: string }[] {
|
||||
return resolveLibraryOptions(candidate)
|
||||
}
|
||||
|
||||
/** Step 1: Store selection from library (does not apply yet). */
|
||||
function handleLibrarySelect(name: string, value: string) {
|
||||
store.pendingSelection[name] = value
|
||||
}
|
||||
|
||||
/** Step 1: Upload file and store result as pending (does not apply yet). */
|
||||
async function handleUpload(file: File, name: string, mediaType: MediaType) {
|
||||
if (!file.type || !file.type.startsWith(`${mediaType}/`)) {
|
||||
useToastStore().addAlert(
|
||||
st(
|
||||
'toastMessages.unsupportedFileType',
|
||||
'Unsupported file type. Please select a valid file.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
|
||||
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(
|
||||
st(
|
||||
'toastMessages.uploadFailed',
|
||||
'Failed to upload file. Please try again.'
|
||||
)
|
||||
)
|
||||
delete store.uploadState[name]
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
const uploadedPath: string = data.subfolder
|
||||
? `${data.subfolder}/${data.name}`
|
||||
: data.name
|
||||
|
||||
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
|
||||
store.pendingSelection[name] = uploadedPath
|
||||
|
||||
// Refresh assets store (non-critical — upload already succeeded)
|
||||
try {
|
||||
await assetsStore.updateInputs()
|
||||
} catch {
|
||||
// Asset list refresh failed but upload is valid; selection can proceed
|
||||
}
|
||||
} catch {
|
||||
useToastStore().addAlert(
|
||||
st(
|
||||
'toastMessages.uploadFailed',
|
||||
'Failed to upload file. Please try again.'
|
||||
)
|
||||
)
|
||||
delete store.uploadState[name]
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 2: Apply pending selection to widgets and remove from missing list. */
|
||||
function confirmSelection(name: string) {
|
||||
const value = store.pendingSelection[name]
|
||||
if (!value || !store.missingMediaCandidates) return
|
||||
|
||||
applyValueToNodes(store.missingMediaCandidates, name, value)
|
||||
store.removeMissingMediaByName(name)
|
||||
delete store.pendingSelection[name]
|
||||
delete store.uploadState[name]
|
||||
}
|
||||
|
||||
function cancelSelection(name: string) {
|
||||
delete store.pendingSelection[name]
|
||||
delete store.uploadState[name]
|
||||
}
|
||||
|
||||
function hasPendingSelection(name: string): boolean {
|
||||
return name in store.pendingSelection
|
||||
}
|
||||
|
||||
return {
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
getAcceptType,
|
||||
getExtensionHint,
|
||||
getLibraryOptions,
|
||||
handleLibrarySelect,
|
||||
handleUpload,
|
||||
confirmSelection,
|
||||
cancelSelection,
|
||||
hasPendingSelection
|
||||
}
|
||||
}
|
||||
26
src/platform/missingMedia/missingMediaGrouping.ts
Normal file
26
src/platform/missingMedia/missingMediaGrouping.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { sumBy } from 'es-toolkit'
|
||||
|
||||
import type { MissingMediaGroup, MissingMediaViewModel } from './types'
|
||||
|
||||
export interface MissingMediaReference {
|
||||
mediaItem: MissingMediaViewModel
|
||||
nodeRef: MissingMediaViewModel['referencingNodes'][number]
|
||||
}
|
||||
|
||||
export function getMissingMediaReferences(
|
||||
groups: MissingMediaGroup[]
|
||||
): MissingMediaReference[] {
|
||||
return groups.flatMap((group) =>
|
||||
group.items.flatMap((mediaItem) =>
|
||||
mediaItem.referencingNodes.map((nodeRef) => ({ mediaItem, nodeRef }))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function countMissingMediaReferences(
|
||||
groups: MissingMediaGroup[]
|
||||
): number {
|
||||
return sumBy(groups, (group) =>
|
||||
sumBy(group.items, (item) => item.referencingNodes.length)
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
groupCandidatesByName,
|
||||
groupCandidatesByMediaType
|
||||
} from './missingMediaScan'
|
||||
import {
|
||||
countMissingMediaReferences,
|
||||
getMissingMediaReferences
|
||||
} from './missingMediaGrouping'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
|
||||
@@ -421,6 +425,11 @@ describe('groupCandidatesByName', () => {
|
||||
|
||||
const photoGroup = result.find((g) => g.name === 'photo.png')
|
||||
expect(photoGroup?.referencingNodes).toHaveLength(2)
|
||||
expect(photoGroup?.referencingNodes[0]).toMatchObject({
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image'
|
||||
})
|
||||
expect(photoGroup?.mediaType).toBe('image')
|
||||
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
|
||||
|
||||
@@ -487,6 +496,27 @@ describe('groupCandidatesByMediaType', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing media references', () => {
|
||||
it('flattens references without deduping shared filenames', () => {
|
||||
const groups = groupCandidatesByMediaType([
|
||||
makeCandidate('1', 'shared.png'),
|
||||
makeCandidate('2', 'shared.png'),
|
||||
makeCandidate('3', 'other.png')
|
||||
])
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].items).toHaveLength(2)
|
||||
expect(countMissingMediaReferences(groups)).toBe(3)
|
||||
expect(
|
||||
getMissingMediaReferences(groups).map(({ nodeRef }) => nodeRef)
|
||||
).toEqual([
|
||||
expect.objectContaining({ nodeId: '1' }),
|
||||
expect.objectContaining({ nodeId: '2' }),
|
||||
expect.objectContaining({ nodeId: '3' })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyMediaCandidates', () => {
|
||||
const existingHash =
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
|
||||
|
||||
@@ -256,6 +256,7 @@ export function groupCandidatesByName(
|
||||
if (existing) {
|
||||
existing.referencingNodes.push({
|
||||
nodeId: c.nodeId,
|
||||
nodeType: c.nodeType,
|
||||
widgetName: c.widgetName
|
||||
})
|
||||
} else {
|
||||
@@ -263,7 +264,9 @@ export function groupCandidatesByName(
|
||||
name: c.name,
|
||||
mediaType: c.mediaType,
|
||||
representative: c,
|
||||
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
||||
referencingNodes: [
|
||||
{ nodeId: c.nodeId, nodeType: c.nodeType, widgetName: c.widgetName }
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,18 +67,12 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
})
|
||||
|
||||
it('clearMissingMedia resets all state including interaction state', () => {
|
||||
it('clearMissingMedia resets candidates and aborts verification', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
store.expandState['photo.png'] = true
|
||||
store.uploadState['photo.png'] = {
|
||||
fileName: 'photo.png',
|
||||
status: 'uploaded'
|
||||
}
|
||||
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
|
||||
const controller = store.createVerificationAbortController()
|
||||
|
||||
store.clearMissingMedia()
|
||||
@@ -87,9 +81,6 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
expect(store.missingMediaCount).toBe(0)
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
expect(store.expandState).toEqual({})
|
||||
expect(store.uploadState).toEqual({})
|
||||
expect(store.pendingSelection).toEqual({})
|
||||
})
|
||||
|
||||
it('missingMediaNodeIds tracks unique node IDs', () => {
|
||||
@@ -145,47 +136,6 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removeMissingMediaByName clears interaction state for removed name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
store.expandState['photo.png'] = true
|
||||
store.uploadState['photo.png'] = {
|
||||
fileName: 'photo.png',
|
||||
status: 'uploaded'
|
||||
}
|
||||
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
|
||||
|
||||
store.removeMissingMediaByName('photo.png')
|
||||
|
||||
expect(store.expandState['photo.png']).toBeUndefined()
|
||||
expect(store.uploadState['photo.png']).toBeUndefined()
|
||||
expect(store.pendingSelection['photo.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget clears interaction state for removed name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
store.pendingSelection['photo.png'] = 'library/photo.png'
|
||||
|
||||
store.removeMissingMediaByWidget('1', 'image')
|
||||
|
||||
expect(store.pendingSelection['photo.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget preserves interaction state when other candidates share the name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'photo.png')
|
||||
])
|
||||
store.pendingSelection['photo.png'] = 'library/photo.png'
|
||||
|
||||
store.removeMissingMediaByWidget('1', 'image')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
|
||||
})
|
||||
|
||||
it('createVerificationAbortController aborts previous controller', () => {
|
||||
const store = useMissingMediaStore()
|
||||
const first = store.createVerificationAbortController()
|
||||
@@ -264,40 +214,6 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
})
|
||||
|
||||
it('cleans interaction state for removed names', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
store.expandState['photo.png'] = true
|
||||
store.uploadState['photo.png'] = {
|
||||
fileName: 'photo.png',
|
||||
status: 'uploaded'
|
||||
}
|
||||
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
|
||||
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
|
||||
expect(store.expandState['photo.png']).toBeUndefined()
|
||||
expect(store.uploadState['photo.png']).toBeUndefined()
|
||||
expect(store.pendingSelection['photo.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves interaction state when other candidates share the name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'photo.png')
|
||||
])
|
||||
store.pendingSelection['photo.png'] = 'library/photo.png'
|
||||
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
|
||||
})
|
||||
|
||||
it('does nothing when candidates are null', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
@@ -397,21 +313,5 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
|
||||
})
|
||||
|
||||
it('clears interaction state for removed names not used elsewhere', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('65:70:63', 'shared.png'),
|
||||
makeCandidate('65:80:5', 'shared.png'),
|
||||
makeCandidate('65:70:64', 'only-interior.png')
|
||||
])
|
||||
store.pendingSelection['shared.png'] = 'library/shared.png'
|
||||
store.pendingSelection['only-interior.png'] = 'library/interior.png'
|
||||
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
|
||||
expect(store.pendingSelection['only-interior.png']).toBeUndefined()
|
||||
expect(store.pendingSelection['shared.png']).toBe('library/shared.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,14 +56,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Interaction state — persists across component re-mounts
|
||||
const expandState = ref<Record<string, boolean>>({})
|
||||
const uploadState = ref<
|
||||
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
|
||||
>({})
|
||||
/** Pending selection: value to apply on confirm. */
|
||||
const pendingSelection = ref<Record<string, string>>({})
|
||||
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
@@ -84,58 +76,20 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
return activeMissingMediaGraphIds.value.has(String(node.id))
|
||||
}
|
||||
|
||||
function clearInteractionStateForName(name: string) {
|
||||
delete expandState.value[name]
|
||||
delete uploadState.value[name]
|
||||
delete pendingSelection.value[name]
|
||||
}
|
||||
|
||||
function removeMissingMediaByName(name: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
||||
(m) => m.name !== name
|
||||
)
|
||||
clearInteractionStateForName(name)
|
||||
if (!missingMediaCandidates.value.length)
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
function removeMissingMediaByWidget(nodeId: string, widgetName: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
const removedNames = new Set(
|
||||
missingMediaCandidates.value
|
||||
.filter(
|
||||
(m) => String(m.nodeId) === nodeId && m.widgetName === widgetName
|
||||
)
|
||||
.map((m) => m.name)
|
||||
)
|
||||
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
||||
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
|
||||
)
|
||||
for (const name of removedNames) {
|
||||
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
if (!missingMediaCandidates.value.length)
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
function removeMissingMediaByNodeId(nodeId: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
const removedNames = new Set(
|
||||
missingMediaCandidates.value
|
||||
.filter((m) => String(m.nodeId) === nodeId)
|
||||
.map((m) => m.name)
|
||||
)
|
||||
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
||||
(m) => String(m.nodeId) !== nodeId
|
||||
)
|
||||
for (const name of removedNames) {
|
||||
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
if (!missingMediaCandidates.value.length)
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
@@ -150,7 +104,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
*/
|
||||
function removeMissingMediaByPrefix(prefix: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
const removedNames = new Set<string>()
|
||||
const remaining: MissingMediaCandidate[] = []
|
||||
for (const m of missingMediaCandidates.value) {
|
||||
// Preserve candidates without a nodeId; they cannot belong to any
|
||||
@@ -160,19 +113,12 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
remaining.push(m)
|
||||
continue
|
||||
}
|
||||
if (String(m.nodeId).startsWith(prefix)) {
|
||||
removedNames.add(m.name)
|
||||
} else {
|
||||
if (!String(m.nodeId).startsWith(prefix)) {
|
||||
remaining.push(m)
|
||||
}
|
||||
}
|
||||
if (removedNames.size === 0) return
|
||||
if (remaining.length === missingMediaCandidates.value.length) return
|
||||
missingMediaCandidates.value = remaining.length ? remaining : null
|
||||
for (const name of removedNames) {
|
||||
if (!remaining.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addMissingMedia(media: MissingMediaCandidate[]) {
|
||||
@@ -193,9 +139,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
missingMediaCandidates.value = null
|
||||
expandState.value = {}
|
||||
uploadState.value = {}
|
||||
pendingSelection.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -208,7 +151,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
|
||||
setMissingMedia,
|
||||
addMissingMedia,
|
||||
removeMissingMediaByName,
|
||||
removeMissingMediaByWidget,
|
||||
removeMissingMediaByNodeId,
|
||||
removeMissingMediaByPrefix,
|
||||
@@ -216,10 +158,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingMediaOnNode,
|
||||
isContainerWithMissingMedia,
|
||||
|
||||
expandState,
|
||||
uploadState,
|
||||
pendingSelection
|
||||
isContainerWithMissingMedia
|
||||
}
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface MissingMediaViewModel {
|
||||
representative: MissingMediaCandidate
|
||||
referencingNodes: Array<{
|
||||
nodeId: NodeId
|
||||
nodeType?: string
|
||||
widgetName: string
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -35,12 +35,17 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
|
||||
let testId = 0
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
delete window.__comfyDesktop2
|
||||
delete window.__comfyDesktop2Remote
|
||||
})
|
||||
|
||||
describe('fetchModelMetadata', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
testId++
|
||||
})
|
||||
|
||||
@@ -242,7 +247,126 @@ describe('downloadModel', () => {
|
||||
beforeEach(() => {
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
})
|
||||
|
||||
it('uses the Desktop2 bridge directly instead of the browser fallback', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).toHaveBeenCalledWith(
|
||||
'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
'model.safetensors',
|
||||
'checkpoints'
|
||||
)
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs Desktop2 bridge failures without falling back to browser download', async () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const bridgeError = new Error('bridge failed')
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockRejectedValue(bridgeError)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to start Desktop2 model download:',
|
||||
bridgeError
|
||||
)
|
||||
})
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs synchronous Desktop2 bridge failures without crashing', async () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const bridgeError = new Error('bridge failed before returning a promise')
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockImplementation(() => {
|
||||
throw bridgeError
|
||||
})
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to start Desktop2 model download:',
|
||||
bridgeError
|
||||
)
|
||||
})
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps remote Desktop2 sessions on the browser fallback', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
window.__comfyDesktop2Remote = true
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).not.toHaveBeenCalled()
|
||||
expect(anchorClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens the model library sidebar before starting a desktop download', () => {
|
||||
|
||||
@@ -3,6 +3,21 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
interface ComfyDesktop2Bridge {
|
||||
downloadModel: (
|
||||
url: string,
|
||||
filename: string,
|
||||
directory: string
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__comfyDesktop2?: ComfyDesktop2Bridge
|
||||
__comfyDesktop2Remote?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
'https://civitai.red/',
|
||||
@@ -35,6 +50,17 @@ export interface ModelWithUrl {
|
||||
directory: string
|
||||
}
|
||||
|
||||
async function startDesktop2ModelDownload(
|
||||
bridge: ComfyDesktop2Bridge,
|
||||
model: ModelWithUrl
|
||||
): Promise<void> {
|
||||
try {
|
||||
await bridge.downloadModel(model.url, model.name, model.directory)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to start Desktop2 model download:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a model download URL to a browsable page URL.
|
||||
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
|
||||
@@ -63,6 +89,12 @@ export function downloadModel(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
const desktop2Bridge = window.__comfyDesktop2
|
||||
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
|
||||
void startDesktop2ModelDownload(desktop2Bridge, model)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDesktop) {
|
||||
const link = document.createElement('a')
|
||||
link.href = model.url
|
||||
|
||||
@@ -208,6 +208,23 @@ describe('GtmTelemetryProvider', () => {
|
||||
expect(entry!.error as string).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('pushes execution_start', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'execution_start'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes execution_success with job_id', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackExecutionSuccess({ jobId: 'job-1' })
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'execution_success',
|
||||
job_id: 'job-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes select_content for template events', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackTemplate({
|
||||
|
||||
@@ -59,8 +59,6 @@ import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ShareFlowMetadata,
|
||||
SurveyResponses,
|
||||
TemplateLibraryClosedMetadata,
|
||||
@@ -288,8 +286,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
}
|
||||
const enterLinearMetadata: EnterLinearMetadata = {}
|
||||
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
|
||||
const executionErrorMetadata: ExecutionErrorMetadata = { jobId: 'job-1' }
|
||||
const executionSuccessMetadata: ExecutionSuccessMetadata = { jobId: 'job-1' }
|
||||
const authMetadata: AuthMetadata = {}
|
||||
|
||||
it.for<
|
||||
@@ -355,16 +351,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
(p) => p.trackShareFlow(shareFlowMetadata),
|
||||
TelemetryEvents.SHARE_FLOW
|
||||
],
|
||||
[
|
||||
'trackExecutionError',
|
||||
(p) => p.trackExecutionError(executionErrorMetadata),
|
||||
TelemetryEvents.EXECUTION_ERROR
|
||||
],
|
||||
[
|
||||
'trackExecutionSuccess',
|
||||
(p) => p.trackExecutionSuccess(executionSuccessMetadata),
|
||||
TelemetryEvents.EXECUTION_SUCCESS
|
||||
],
|
||||
[
|
||||
'trackAuth',
|
||||
(p) => p.trackAuth(authMetadata),
|
||||
@@ -422,27 +408,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('trackWorkflowExecution forwards the latest trigger_source from trackRunButton', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
provider.trackRunButton({ trigger_source: 'keybinding' })
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.EXECUTION_START,
|
||||
expect.objectContaining({ trigger_source: 'keybinding' })
|
||||
)
|
||||
|
||||
mockMixpanel.track.mockClear()
|
||||
provider.trackWorkflowExecution()
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.EXECUTION_START,
|
||||
expect.objectContaining({ trigger_source: 'unknown' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MixpanelTelemetryProvider — topup delegation', () => {
|
||||
|
||||
@@ -18,10 +18,7 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionTriggerSource,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
@@ -92,7 +89,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
private mixpanel: OverridedMixpanel | null = null
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
|
||||
constructor() {
|
||||
@@ -300,7 +296,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
@@ -420,24 +415,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = getExecutionContext()
|
||||
const eventContext: ExecutionContext = {
|
||||
...context,
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown'
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
|
||||
this.lastTriggerSource = undefined
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
@@ -102,7 +99,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private pendingFirstAuthAt = new Map<string, string>()
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
private desktopEntryProps: DesktopEntryProps | null = null
|
||||
|
||||
@@ -400,7 +396,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
@@ -532,24 +527,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = getExecutionContext()
|
||||
const eventContext: ExecutionContext = {
|
||||
...context,
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown'
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
|
||||
this.lastTriggerSource = undefined
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useImageQuiet } from '@/composables/useImageQuiet'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { name, previewUrl } = defineProps<{
|
||||
@@ -63,5 +63,5 @@ const imageOptions = computed(() => ({
|
||||
src: normalizedPreviewUrl.value ?? ''
|
||||
}))
|
||||
|
||||
const { isReady, isLoading, error } = useImage(imageOptions)
|
||||
const { isReady, isLoading, error } = useImageQuiet(imageOptions)
|
||||
</script>
|
||||
|
||||
@@ -38,6 +38,15 @@ describe('widgetStore', () => {
|
||||
store.registerCustomWidgets({ INT: override })
|
||||
expect(store.widgets.get('INT')).toBe(ComfyWidgets.INT)
|
||||
})
|
||||
|
||||
it('does not throw when an extension returns null/undefined widgets', () => {
|
||||
const store = useWidgetStore()
|
||||
// Regression: a misbehaving extension can resolve getCustomWidgets() to
|
||||
// nullish, which must not break app init. The `!` casts deliberately
|
||||
// violate the non-null parameter type to simulate that untrusted input.
|
||||
expect(() => store.registerCustomWidgets(undefined!)).not.toThrow()
|
||||
expect(() => store.registerCustomWidgets(null!)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputIsWidget', () => {
|
||||
|
||||
@@ -22,6 +22,11 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
function registerCustomWidgets(
|
||||
newWidgets: Record<string, ComfyWidgetConstructor>
|
||||
) {
|
||||
// Extensions are untrusted code: `getCustomWidgets` is typed to return
|
||||
// `Record<string, ...>`, but in practice an extension can resolve it to
|
||||
// null/undefined. Guard here so a single misbehaving custom node can't
|
||||
// throw "Cannot convert undefined or null to object" and break app init.
|
||||
if (!newWidgets) return
|
||||
for (const [type, widget] of Object.entries(newWidgets)) {
|
||||
customWidgets.value.set(type, widget)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user