Compare commits

..

3 Commits

Author SHA1 Message Date
Alexander Brown
43d9f492b1 Apply suggestion from @DrJKL 2026-06-09 11:22:29 -07:00
Michael B
451d594f23 feat(website): wire English subtitle tracks for learning tutorials 2026-06-08 12:55:41 -04:00
Michael B
9ffdcd17b6 fix: show featured workflow video before copy on mobile 2026-06-08 12:11:54 -04:00
276 changed files with 4418 additions and 14285 deletions

View File

@@ -1,18 +1,38 @@
import { expect } from '@playwright/test'
import { affiliateFaqs } from '../src/data/affiliateFaq'
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 { 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 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')
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
test.describe('Affiliates landing — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -21,38 +41,32 @@ test.describe('Affiliates landing — desktop @smoke', () => {
test('renders the hero heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
).toBeVisible()
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
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('CTA section links to the affiliate terms page in the same tab', async ({
test('renders every page section in top-to-bottom order', 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')
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 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)
})
})
@@ -79,21 +93,42 @@ 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(FAQ_COUNT)
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')
})
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 applyButton.click()
await page.getByTestId('affiliate-hero-cta').click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
const popupUrl = popup.url()
@@ -105,15 +140,12 @@ 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.en
})
await firstQuestion.scrollIntoViewIfNeeded()
const firstQuestion = page.getByRole('button', { name: FIRST_FAQ_QUESTION })
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
await expect(page.getByText(FIRST_FAQ_ANSWER)).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
@@ -125,24 +157,44 @@ test.describe('Affiliates landing — mobile @mobile', () => {
await page.goto(PATH)
})
test('renders the hero heading at narrow viewports', async ({ page }) => {
test('renders the hero heading and primary CTA at narrow viewports', async ({
page
}) => {
await expect(
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
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)
).toBeVisible()
})
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
)
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
)
}
})
})

View File

@@ -4,10 +4,6 @@ import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const LINUX_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -42,7 +38,7 @@ test.describe('Download page @smoke', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
@@ -56,61 +52,6 @@ test.describe('Download page @smoke', () => {
await context.close()
})
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
browser
}) => {
const context = await browser.newContext({ userAgent: LINUX_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const windowsBtn = hero.locator(
'a[href="https://download.comfy.org/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
const macBtn = hero.locator(
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
)
await expect(macBtn).toBeVisible()
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toHaveCount(2)
await context.close()
})
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
const context = await browser.newContext({ userAgent: IPHONE_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toBeHidden()
await expect(
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
).toBeVisible()
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
@@ -235,7 +176,7 @@ test.describe('Download page mobile @mobile', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(downloadBtn).toBeVisible()

View File

@@ -213,7 +213,7 @@ test.describe('Get started section links @smoke', () => {
has: page.getByRole('heading', { name: 'Get started in minutes' })
})
const downloadLink = section.getByRole('link', { name: 'Download Desktop' })
const downloadLink = section.getByRole('link', { name: 'Download Local' })
await expect(downloadLink).toBeVisible()
await expect(downloadLink).toHaveAttribute('href', '/download')

View File

@@ -32,7 +32,7 @@ test.describe('Desktop navigation @smoke', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
await expect(
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
@@ -55,7 +55,7 @@ test.describe('Desktop dropdown @interaction', () => {
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
for (const item of [
'Comfy Desktop',
'Comfy Local',
'Comfy Cloud',
'Comfy API',
'Comfy Enterprise'
@@ -69,7 +69,7 @@ test.describe('Desktop dropdown @interaction', () => {
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.locator('main').hover()
@@ -81,7 +81,7 @@ test.describe('Desktop dropdown @interaction', () => {
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.keyboard.press('Escape')
@@ -121,7 +121,7 @@ test.describe('Mobile menu @mobile', () => {
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
await expect(menu.getByText('Comfy Local')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
@@ -133,7 +133,7 @@ test.describe('Mobile menu @mobile', () => {
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 234 B

View File

@@ -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 Imagine', slug: 'grok-imagine' },
grok: { name: 'Grok Image', slug: 'grok-image' },
stability: { name: 'Stability AI', slug: 'stability-ai' },
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
@@ -86,20 +86,6 @@ 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)$/, '')
}
@@ -313,8 +299,7 @@ function run(): void {
throw new Error(
`Failed to parse ${file}: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
}`
)
}
}
@@ -382,7 +367,7 @@ function run(): void {
displayName: m.name
}))
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
const combined = [...apiOutput, ...output]
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
process.stdout.write(

View File

@@ -0,0 +1,42 @@
<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>

View File

@@ -0,0 +1,62 @@
<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>

View File

@@ -0,0 +1,43 @@
<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>

View File

@@ -0,0 +1,84 @@
<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>

View File

@@ -0,0 +1,55 @@
<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>

View File

@@ -0,0 +1,79 @@
<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>

View File

@@ -0,0 +1,20 @@
<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>

View File

@@ -0,0 +1,6 @@
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

View File

@@ -0,0 +1,154 @@
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([])
})
})

View File

@@ -0,0 +1,59 @@
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'
}
]

View File

@@ -0,0 +1,33 @@
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'
}
]

View File

@@ -1,60 +0,0 @@
<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>

View File

@@ -1,65 +0,0 @@
<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>

View File

@@ -1,56 +0,0 @@
<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>

View File

@@ -1,50 +0,0 @@
<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>

View File

@@ -1,94 +0,0 @@
<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>

View File

@@ -1,153 +0,0 @@
<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>

View File

@@ -1,91 +0,0 @@
<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>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
import WireNodeLayout from '../common/WireNodeLayout.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasons: TranslationKey[] = [
const reasons = [
'careers.whyJoin.reason1',
'careers.whyJoin.reason2',
'careers.whyJoin.reason3',
'careers.whyJoin.reason4',
'careers.whyJoin.reason5'
]
] as const
</script>
<template>

View File

@@ -1,13 +1,19 @@
<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 props = defineProps<{
const {
href,
target,
rel,
variant,
size,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
@@ -15,25 +21,15 @@ const props = defineProps<{
size?: BrandButtonVariants['size']
class?: HTMLAttributes['class']
}>()
const resolvedRel = computed(
() =>
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
)
</script>
<template>
<component
:is="props.href ? 'a' : 'button'"
:href="props.href"
:target="props.target"
:rel="resolvedRel"
:class="
cn(
brandButtonVariants({ variant: props.variant, size: props.size }),
props.class ?? ''
)
"
:is="href ? 'a' : 'button'"
:href
:target
:rel
:class="cn(brandButtonVariants({ variant, size }), customClass)"
>
<span class="ppformula-text-center">
<slot />

View File

@@ -1,10 +1,20 @@
<script setup lang="ts">
import type { EventItem } from '../../content.config'
import type { Locale, TranslationKey } from '../../i18n/translations'
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
export type EventItem = {
label: LocalizedText
title: LocalizedText
cta: LocalizedText
href: string
}
const {
locale = 'en',
headingKey,
@@ -30,12 +40,12 @@ const {
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
@@ -56,20 +66,20 @@ const {
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
>
<span
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
>
{{ event.label }}
{{ event.label[locale] }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title }}
{{ event.title[locale] }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta }}
{{ event.cta[locale] }}
<svg
class="size-4 transition-transform group-hover:translate-x-0.5"
viewBox="0 0 24 24"

View File

@@ -2,7 +2,7 @@
const {
logoSrc = '/icons/logo.svg',
logoAlt = 'Comfy',
text = 'DESKTOP'
text = 'LOCAL'
} = defineProps<{
logoSrc?: string
logoAlt?: string
@@ -20,7 +20,7 @@ const {
/>
<span
class="bg-primary-comfy-yellow my-auto flex h-12 items-center justify-center text-primary-comfy-ink lg:my-0 lg:h-auto lg:p-8"
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
>
<img
:src="logoSrc"
@@ -37,7 +37,7 @@ const {
/>
<span
class="bg-primary-comfy-yellow my-auto flex h-7.25 items-center justify-center text-primary-comfy-ink lg:h-15.5 lg:px-6"
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
>
<span
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"

View File

@@ -94,7 +94,7 @@ const ctaButtons = [
{
label: t('nav.downloadLocal', locale),
prefix: 'DOWNLOAD',
core: 'DESKTOP',
core: 'LOCAL',
href: routes.download,
primary: false
},
@@ -164,7 +164,7 @@ onMounted(() => {
/>
<nav
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

@@ -13,33 +13,23 @@ import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import { t } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
import type { VideoTrack } from '../../types/video'
import PlayPauseButton from './PlayPauseButton.vue'
type VideoTrack = {
src: string
kind: 'subtitles' | 'captions' | 'descriptions'
srclang: string
label: string
}
const {
locale = 'en',
src,
poster,
tracks = [],
autoplay = false,
loop = false,
minimal = false,
hideControls = false
minimal = false
} = defineProps<{
locale?: Locale
src?: string
poster?: string
tracks?: VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean
hideControls?: boolean
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -204,9 +194,8 @@ function toggleFullscreen() {
crossorigin="anonymous"
playsinline
:autoplay
:loop
muted
@click="hideControls ? undefined : (playing = !playing)"
@click="playing = !playing"
>
<track
v-for="track in tracks"
@@ -220,7 +209,7 @@ function toggleFullscreen() {
<!-- Minimal centered play/pause button -->
<div
v-if="minimal && src && !hideControls"
v-if="minimal && src"
:class="
cn(
'absolute inset-0 flex items-center justify-center transition-opacity duration-300',
@@ -240,7 +229,7 @@ function toggleFullscreen() {
<!-- Bottom control bar -->
<div
v-if="src && !minimal && !hideControls"
v-if="src && !minimal"
: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',

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryItemAttribution from './GalleryItemAttribution.vue'
@@ -7,16 +7,12 @@ const {
item,
locale = 'en',
aspect = 'var(--aspect-ratio-gallery-card)',
mobile = false,
objectPosition = 'center',
objectFit = 'cover'
mobile = false
} = defineProps<{
item: GalleryItem
locale?: Locale
aspect?: string
mobile?: boolean
objectPosition?: string
objectFit?: string
}>()
defineEmits<{ click: [] }>()
@@ -35,15 +31,13 @@ defineEmits<{ click: [] }>()
loop
muted
playsinline
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<img
v-else
:src="item.image"
:alt="item.title"
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<!-- Desktop hover overlay -->
<div
@@ -53,7 +47,7 @@ defineEmits<{ click: [] }>()
<div class="flex w-full items-end justify-between p-4">
<div class="gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>
@@ -82,7 +76,7 @@ defineEmits<{ click: [] }>()
<!-- Mobile metadata -->
<div v-if="mobile" class="mt-2 gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>

View File

@@ -12,7 +12,7 @@ import {
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
@@ -123,11 +123,11 @@ onUnmounted(() => {
<!-- Close button -->
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:top-8 lg:right-26"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:top-8 lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
@@ -136,7 +136,7 @@ onUnmounted(() => {
<div class="relative hidden min-h-0 w-full flex-1 pt-12 lg:flex">
<!-- Left: info card -->
<div
class="bg-primary-comfy-yellow rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8 text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"
@@ -170,7 +170,7 @@ onUnmounted(() => {
<!-- Right: large image -->
<div
class="border-primary-comfy-yellow rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-4"
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 p-4"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -197,7 +197,7 @@ onUnmounted(() => {
>
<!-- Image -->
<div
class="border-primary-comfy-yellow flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 bg-primary-comfy-ink p-3"
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -223,7 +223,7 @@ onUnmounted(() => {
<!-- Info card -->
<div
class="bg-primary-comfy-yellow w-full rounded-4xl p-6 text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'

View File

@@ -2,15 +2,13 @@
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { GalleryItem } from '../../content.config'
import { visibleGalleryItems as items } from '../../data/gallery'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryCard from './GalleryCard.vue'
import GalleryDetailModal from './GalleryDetailModal.vue'
const { items, locale = 'en' } = defineProps<{
items: GalleryItem[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modalOpen = ref(false)
const modalIndex = ref(0)

View File

@@ -1,23 +0,0 @@
<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>

View File

@@ -1,32 +0,0 @@
<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>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import type { VideoTrack } from '../../types/video'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
@@ -13,15 +14,23 @@ const demoVideoSrc =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4'
const demoVideoPoster =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg'
const demoVideoTracks: VideoTrack[] = [
{
src: 'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_vtt.en.vtt',
kind: 'subtitles',
srclang: 'en',
label: 'English'
}
]
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<div class="order-last flex flex-col gap-8 lg:order-0">
<div>
<h2
class="text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t('learning.featured.title', locale) }}
</h2>
@@ -54,11 +63,14 @@ const demoVideoPoster =
</ul>
</div>
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
<div
class="border-primary-warm-gray rounded-4.5xl order-first border p-4 lg:order-0"
>
<VideoPlayer
:locale
:src="demoVideoSrc"
:poster="demoVideoPoster"
:tracks="demoVideoTracks"
minimal
/>
</div>

View File

@@ -1,21 +1,35 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
import type { ResolvedTutorial } from '../../content.config'
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: ResolvedTutorial
tutorial: LearningTutorial
locale?: Locale
}>()
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')
@@ -28,6 +42,7 @@ function handleKeydown(e: KeyboardEvent) {
onMounted(() => {
lockScroll()
dialogRef.value?.showModal()
playFromStart()
})
onUnmounted(() => {
@@ -39,7 +54,7 @@ onUnmounted(() => {
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title"
:aria-label="tutorial.title[locale]"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@@ -59,21 +74,32 @@ onUnmounted(() => {
<div
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"
>
<VideoPlayer
:key="tutorial.slug"
:locale
<video
ref="videoRef"
:src="tutorial.videoSrc"
:poster="tutorial.poster"
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
crossorigin="anonymous"
controls
autoplay
class="w-full"
/>
playsinline
>
<track
v-for="track in tutorial.tracks ?? []"
:key="track.src"
:src="track.src"
:kind="track.kind"
:srclang="track.srclang"
:label="track.label"
/>
</video>
</div>
<h2
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title }}
{{ tutorial.title[locale] }}
</h2>
</dialog>
</Teleport>

View File

@@ -1,29 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ResolvedTutorial } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import { getTutorialPosterSrc } from '../../utils/tutorial'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { tutorials, locale = 'en' } = defineProps<{
tutorials: readonly ResolvedTutorial[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeTutorialSlug = ref<string | null>(null)
const activeTutorialId = ref<string | null>(null)
const activeTutorial = () =>
tutorials.find((tutorial) => tutorial.slug === activeTutorialSlug.value)
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-12 text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
>
{{ t('learning.tutorials.heading', locale) }}
</h2>
@@ -32,15 +31,15 @@ const activeTutorial = () =>
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<li
v-for="tutorial in tutorials"
:key="tutorial.slug"
v-for="tutorial in learningTutorials"
:key="tutorial.id"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title}`"
@click="activeTutorialSlug = tutorial.slug"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
@click="activeTutorialId = tutorial.id"
>
<video
:src="getTutorialPosterSrc(tutorial)"
@@ -72,10 +71,10 @@ const activeTutorial = () =>
<div class="flex flex-col space-y-3 p-4">
<div class="flex items-center justify-between gap-4">
<h3
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title }}
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
{{ tutorial.title[locale] }}
</h3>
<MaskRevealButton
v-if="tutorial.href"
@@ -104,7 +103,7 @@ const activeTutorial = () =>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ tag }}</Badge>
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
@@ -115,7 +114,7 @@ const activeTutorial = () =>
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialSlug = null"
@close="activeTutorialId = null"
/>
</section>
</template>

View File

@@ -1,102 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../content.config'
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 { items, locale = 'en' } = defineProps<{
items: readonly GalleryItem[]
locale?: Locale
}>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
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>

View File

@@ -1,62 +0,0 @@
<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>

View File

@@ -1,7 +1,6 @@
<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 }>()
@@ -95,9 +94,12 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<CheckIcon
<img
v-else
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
src="/icons/check.svg"
alt=""
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}

View File

@@ -3,10 +3,7 @@ import type { Locale } from '../../../i18n/translations'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import {
downloadUrls,
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import BrandButton from '../../common/BrandButton.vue'
@@ -15,64 +12,32 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { downloadUrl, platform, showFallback } = useDownloadUrl()
const { downloadUrl, platform } = useDownloadUrl()
const ICONS = {
windows: '/icons/os/windows.svg',
mac: '/icons/os/apple.svg'
} as const
interface ButtonSpec {
key: string
href: string
icon: string
ariaLabel?: string
}
const buttons = computed<ButtonSpec[]>(() => {
if (platform.value) {
return [
{
key: platform.value,
href: downloadUrl.value,
icon: ICONS[platform.value]
}
]
const iconSrc = computed(() => {
switch (platform.value) {
case 'mac':
return '/icons/os/apple.svg'
case 'windows':
return '/icons/os/windows.svg'
default:
return undefined
}
if (showFallback.value) {
const label = t('download.hero.downloadLocal', locale)
return [
{
key: 'windows',
href: downloadUrls.windows,
icon: ICONS.windows,
ariaLabel: `${label} — Windows`
},
{
key: 'mac',
href: downloadUrls.macArm,
icon: ICONS.mac,
ariaLabel: `${label} — macOS`
}
]
}
return []
})
</script>
<template>
<BrandButton
v-for="btn in buttons"
:key="btn.key"
:href="btn.href"
v-show="platform"
:href="downloadUrl"
target="_blank"
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
>
<span class="inline-flex items-center gap-2">
<img
:src="btn.icon"
v-if="iconSrc"
:src="iconSrc"
alt=""
class="ppformula-text-center size-5 -translate-y-0.75"
aria-hidden="true"

View File

@@ -283,16 +283,16 @@ onUnmounted(() => {
<div
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[10%] lg:px-20 lg:py-14"
>
<ProductHeroBadge text="DESKTOP" />
<ProductHeroBadge />
<h1
class="mt-6 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
class="text-primary-comfy-canvas mt-6 text-3xl/tight font-light whitespace-pre-line md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
>
{{ t('download.hero.heading', locale) }}
</h1>
<p
class="mt-6 max-w-md text-sm text-primary-comfy-canvas lg:mt-6 lg:text-base"
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
>
{{ t('download.hero.subtitle', locale) }}
</p>

View File

@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
<template>
<section
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 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-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
>
{{ t('cloud.aiModels.heading', locale) }}
</h2>
<p
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
>
{{ t('cloud.aiModels.subtitle', locale) }}
</p>
<div class="mt-16 w-full lg:mt-24">
<div class="mt-24 w-full">
<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,15 +180,14 @@ function getCardClass(layoutClass: string): string {
<BrandButton
:href="externalLinks.workflows"
variant="outline"
size="lg"
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
class="mt-4 w-full max-w-md 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>

View File

@@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
export const downloadUrls = {
const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const
@@ -24,8 +24,6 @@ function detectPlatform(ua: string): DetectedPlatform {
// When Linux and/or macIntel builds are added, extend detection and URLs here.
export function useDownloadUrl() {
const platform = ref<DetectedPlatform>(null)
const detected = ref(false)
const isMobileUa = ref(false)
const downloadUrl = computed(() => {
if (platform.value === 'windows') return downloadUrls.windows
@@ -33,16 +31,9 @@ export function useDownloadUrl() {
return externalLinks.github
})
const showFallback = computed(
() => detected.value && !platform.value && !isMobileUa.value
)
onMounted(() => {
const ua = navigator.userAgent.toLowerCase()
isMobileUa.value = isMobile(ua)
platform.value = detectPlatform(ua)
detected.value = true
platform.value = detectPlatform(navigator.userAgent.toLowerCase())
})
return { downloadUrl, platform, showFallback }
return { downloadUrl, platform }
}

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
hubSlug: 'seedance',
featured: true
},
'grok-imagine': {
'grok-image': {
hubSlug: 'grok',
featured: false
},

View File

@@ -1,131 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('astro:content', () => ({
defineCollection: (config: unknown) => config
}))
vi.mock('astro/loaders', () => ({
glob: () => ({})
}))
import { eventsSchema, gallerySchema, tutorialsSchema } from './content.config'
const validEntry = {
order: 1,
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
href: 'https://www.thinkdiffusion.com/studio'
}
describe('gallerySchema', () => {
it('accepts a valid entry', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEntry
const result = gallerySchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('defaults visible to true when omitted', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(true)
}
})
it('preserves an explicit visible: false', () => {
const result = gallerySchema.safeParse({ ...validEntry, visible: false })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(false)
}
})
it('rejects an invalid URL in image/video/href', () => {
const result = gallerySchema.safeParse({
...validEntry,
href: 'not a url'
})
expect(result.success).toBe(false)
})
})
const validEvent = {
order: 1,
label: 'Live Stream:',
title: 'Zero to Node: Building Your First Workflow',
cta: 'Link',
href: 'https://example.com/event'
}
describe('eventsSchema', () => {
it('accepts a valid entry', () => {
const result = eventsSchema.safeParse(validEvent)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEvent
const result = eventsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('accepts "#" as a placeholder href', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: '#' })
expect(result.success).toBe(true)
})
it('rejects a truly malformed href that is neither a URL nor "#"', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: 'not a url' })
expect(result.success).toBe(false)
})
})
const validTutorial = {
order: 1,
tags: ['Partner Nodes', 'Image To Video'],
title: 'Cleanplate Walkthrough',
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg'
}
describe('tutorialsSchema', () => {
it('accepts a valid entry', () => {
const result = tutorialsSchema.safeParse(validTutorial)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validTutorial
const result = tutorialsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('rejects an invalid videoSrc URL', () => {
const result = tutorialsSchema.safeParse({
...validTutorial,
videoSrc: 'not a url'
})
expect(result.success).toBe(false)
})
})

View File

@@ -1,78 +0,0 @@
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'
export const gallerySchema = z.object({
order: z.number().int(),
image: z.string().url().optional(),
video: z.string().url().optional(),
title: z.string(),
userAlias: z.string(),
teamAlias: z.string(),
tool: z.string(),
href: z.string().url().optional(),
objectPosition: z.string().optional(),
objectFit: z.string().optional(),
visible: z.boolean().default(true)
})
export type GalleryItem = z.infer<typeof gallerySchema>
export const eventsSchema = z.object({
order: z.number().int(),
label: z.string(),
title: z.string(),
cta: z.string(),
href: z.string().url().or(z.literal('#'))
})
export type EventItem = z.infer<typeof eventsSchema>
export const tutorialsSchema = z.object({
order: z.number().int(),
tags: z.array(z.string()),
title: z.string(),
videoSrc: z.string().url(),
href: z.string().url().optional(),
poster: z.string().url().optional(),
posterTime: z.number().optional()
})
export type LearningTutorial = z.infer<typeof tutorialsSchema>
export type ResolvedTutorial = LearningTutorial & { slug: string }
// The default `generateId` lowercases path segments (e.g. `zh-CN/foo` becomes
// `zh-cn/foo`), which collides with the BCP-47 locale codes Astro's i18n
// config uses elsewhere. Strip the file extension to keep the path — and
// therefore the locale prefix — verbatim.
const preservePathId = ({ entry }: { entry: string }): string =>
entry.replace(/\.[^.]+$/, '')
const gallery = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/gallery',
generateId: preservePathId
}),
schema: gallerySchema
})
const events = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/events',
generateId: preservePathId
}),
schema: eventsSchema
})
const tutorials = defineCollection({
loader: glob({
pattern: '**/*.md',
base: './src/content/tutorials',
generateId: preservePathId
}),
schema: tutorialsSchema
})
export const collections = { gallery, events, tutorials }

View File

@@ -1,7 +0,0 @@
{
"order": 2,
"label": "Event 1",
"title": "Lorem ipsum dollar sita met",
"cta": "London, UK",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 3,
"label": "Event 2",
"title": "Lorem ipsum dollar sita met",
"cta": "San Francisco",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 1,
"label": "Live Stream:",
"title": "Zero to Node: Building Your First Workflow",
"cta": "Link",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 2,
"label": "活动 1",
"title": "此处为活动描述的占位文本",
"cta": "英国伦敦",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 3,
"label": "活动 2",
"title": "此处为活动描述的占位文本",
"cta": "旧金山",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 1,
"label": "直播:",
"title": "从零到节点:构建你的第一个工作流",
"cta": "链接",
"href": "#"
}

View File

@@ -1,9 +0,0 @@
{
"order": 17,
"image": "https://media.comfy.org/website/gallery/gallery.webp",
"title": "Amber Astronaut",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,11 +0,0 @@
{
"order": 21,
"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",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 16,
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
"title": "Animation Reel",
"userAlias": "Andidea",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
}

View File

@@ -1,9 +0,0 @@
{
"order": 5,
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
"title": "Autopoiesis",
"userAlias": "Yogo",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/visualfrisson/?hl=en"
}

View File

@@ -1,9 +0,0 @@
{
"order": 13,
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
"title": "Cuco - A Love Letter To LA",
"userAlias": "Paul Trillo",
"teamAlias": "CoffeeVectors",
"tool": "ComfyUI",
"href": "https://vimeo.com/1062859798"
}

View File

@@ -1,9 +0,0 @@
{
"order": 12,
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
"title": "DDU-DU DDU-DU",
"userAlias": "Purz",
"teamAlias": "Andidea",
"tool": "Animatediff",
"href": "https://vimeo.com/1019924290"
}

View File

@@ -1,9 +0,0 @@
{
"order": 18,
"image": "https://media.comfy.org/website/gallery/desert.webp",
"title": "Desert Landing",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 6,
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
"title": "Eat It - Dance",
"userAlias": "Johana Lyu",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.joannalyu.com/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 7,
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
"title": "Fall",
"userAlias": "Nathan Shipley",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 11,
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
"title": "It's gonna be a good good summer",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685900"
}

View File

@@ -1,9 +0,0 @@
{
"order": 15,
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
"title": "Goodbye Beijing",
"userAlias": "Rui",
"teamAlias": "makeitrad",
"tool": "Animatediff",
"href": "https://x.com/rui40000"
}

View File

@@ -1,10 +0,0 @@
{
"order": 23,
"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",
"visible": false
}

View File

@@ -1,10 +0,0 @@
{
"order": 20,
"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",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 3,
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
"title": "Neon Nights",
"userAlias": "ShaneF Motion Design",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
}

View File

@@ -1,10 +0,0 @@
{
"order": 22,
"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",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 9,
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
"title": "Origami world",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 2,
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
"title": "Origins - Kyrie Irving",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://vimeo.com/1021360563"
}

View File

@@ -1,9 +0,0 @@
{
"order": 10,
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
"title": "Shot on InstaX",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 14,
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
"title": "Show you my garden",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685479"
}

View File

@@ -1,10 +0,0 @@
{
"order": 19,
"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",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 1,
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
"title": "Until Our Eye Interlink harajuku",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
}

View File

@@ -1,9 +0,0 @@
{
"order": 8,
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
"title": "Untitled",
"userAlias": "Nathan Shipley",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 4,
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
"title": "Untitled",
"userAlias": "MidJourney man",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
}

View File

@@ -1,9 +0,0 @@
{
"order": 17,
"image": "https://media.comfy.org/website/gallery/gallery.webp",
"title": "Amber Astronaut",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 16,
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
"title": "Animation Reel",
"userAlias": "Andidea",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
}

View File

@@ -1,9 +0,0 @@
{
"order": 5,
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
"title": "Autopoiesis",
"userAlias": "Yogo",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/visualfrisson/?hl=en"
}

View File

@@ -1,9 +0,0 @@
{
"order": 13,
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
"title": "Cuco - A Love Letter To LA",
"userAlias": "Paul Trillo",
"teamAlias": "CoffeeVectors",
"tool": "ComfyUI",
"href": "https://vimeo.com/1062859798"
}

View File

@@ -1,9 +0,0 @@
{
"order": 12,
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
"title": "DDU-DU DDU-DU",
"userAlias": "Purz",
"teamAlias": "Andidea",
"tool": "Animatediff",
"href": "https://vimeo.com/1019924290"
}

View File

@@ -1,9 +0,0 @@
{
"order": 18,
"image": "https://media.comfy.org/website/gallery/desert.webp",
"title": "Desert Landing",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 6,
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
"title": "Eat It - Dance",
"userAlias": "Johana Lyu",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.joannalyu.com/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 7,
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
"title": "Fall",
"userAlias": "Nathan Shipley",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 11,
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
"title": "It's gonna be a good good summer",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685900"
}

View File

@@ -1,9 +0,0 @@
{
"order": 15,
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
"title": "Goodbye Beijing",
"userAlias": "Rui",
"teamAlias": "makeitrad",
"tool": "Animatediff",
"href": "https://x.com/rui40000"
}

View File

@@ -1,9 +0,0 @@
{
"order": 3,
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
"title": "Neon Nights",
"userAlias": "ShaneF Motion Design",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 9,
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
"title": "Origami world",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

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