Compare commits
9 Commits
feat/learn
...
feat/node-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e1ede9a89 | ||
|
|
8657bff7d9 | ||
|
|
ddf9308cd8 | ||
|
|
775201f06d | ||
|
|
f4088bcc63 | ||
|
|
75e2c3ef9b | ||
|
|
60db6e85bf | ||
|
|
f61a3212a9 | ||
|
|
705f36c81a |
200
apps/website/e2e/affiliates.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from '../src/components/affiliates/affiliateFaqs'
|
||||
import { programDetailRows } from '../src/components/affiliates/programDetails'
|
||||
import type { TranslationKey } from '../src/i18n/translations'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const FIRST_FAQ_QUESTION = t(
|
||||
`${AFFILIATE_FAQ_PREFIX}.1.q` as TranslationKey,
|
||||
'en'
|
||||
)
|
||||
const FIRST_FAQ_ANSWER = t(
|
||||
`${AFFILIATE_FAQ_PREFIX}.1.a` as TranslationKey,
|
||||
'en'
|
||||
)
|
||||
const FIRST_PROGRAM_DETAIL_LABEL = t(programDetailRows[0].labelKey, 'en')
|
||||
const FIRST_PROGRAM_DETAIL_VALUE = t(programDetailRows[0].valueKey, 'en')
|
||||
const PROGRAM_DETAIL_TABLE_ROW_COUNT = programDetailRows.length + 1
|
||||
|
||||
const PATH = '/affiliates'
|
||||
const APPLY_URL = 'https://forms.gle/RS8L2ttcuGap4Q1v6'
|
||||
|
||||
const SECTION_TESTIDS = [
|
||||
'affiliate-hero',
|
||||
'affiliate-trust-band',
|
||||
'affiliate-how-it-works',
|
||||
'affiliate-audience',
|
||||
'affiliate-program-details',
|
||||
'affiliate-brand-assets',
|
||||
'affiliate-footer-cta'
|
||||
] as const
|
||||
|
||||
test.describe('Affiliates landing — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading and is indexable', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('renders every page section in top-to-bottom order', async ({
|
||||
page
|
||||
}) => {
|
||||
const ys: number[] = []
|
||||
for (const id of SECTION_TESTIDS) {
|
||||
const section = page.getByTestId(id)
|
||||
await expect(section).toBeVisible()
|
||||
const box = await section.boundingBox()
|
||||
expect(box, `${id} bounding box`).not.toBeNull()
|
||||
ys.push(box!.y)
|
||||
}
|
||||
const sortedYs = [...ys].sort((a, b) => a - b)
|
||||
expect(ys).toEqual(sortedYs)
|
||||
})
|
||||
|
||||
test('renders the program details table on desktop', async ({ page }) => {
|
||||
const table = page.getByTestId('affiliate-program-details-table')
|
||||
await expect(table).toBeVisible()
|
||||
const rows = table.getByRole('row')
|
||||
await expect(rows).toHaveCount(PROGRAM_DETAIL_TABLE_ROW_COUNT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliates landing — desktop interactions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('emits FAQPage structured data with one entry per FAQ', async ({
|
||||
page
|
||||
}) => {
|
||||
const faqJsonLd = await page.evaluate(() => {
|
||||
const scripts = Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/ld+json"]'
|
||||
)
|
||||
)
|
||||
const match = scripts.find((s) =>
|
||||
(s.textContent ?? '').includes('FAQPage')
|
||||
)
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(AFFILIATE_FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('hero and footer CTAs target the application form in a new tab', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroCta = page.getByTestId('affiliate-hero-cta')
|
||||
await expect(heroCta).toBeVisible()
|
||||
await expect(heroCta).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(heroCta).toHaveAttribute('target', '_blank')
|
||||
await expect(heroCta).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
const footerCta = page.getByTestId('affiliate-footer-cta-button')
|
||||
await expect(footerCta).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(footerCta).toHaveAttribute('target', '_blank')
|
||||
await expect(footerCta).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
test('footer links to the affiliate terms page as a same-tab navigation', async ({
|
||||
page
|
||||
}) => {
|
||||
const link = page
|
||||
.getByTestId('affiliate-footer-cta')
|
||||
.getByRole('link', { name: /Read the affiliate program terms/i })
|
||||
await expect(link).toBeVisible()
|
||||
await expect(link).toBeEnabled()
|
||||
await expect(link).toHaveAttribute('href', '/affiliates/terms')
|
||||
await expect(link).not.toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
const popupPromise = context.waitForEvent('page')
|
||||
await page.getByTestId('affiliate-hero-cta').click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded')
|
||||
const popupUrl = popup.url()
|
||||
expect(
|
||||
popupUrl.includes('forms.gle/RS8L2ttcuGap4Q1v6') ||
|
||||
popupUrl.includes('docs.google.com/forms')
|
||||
).toBe(true)
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('FAQ items toggle open and closed on click', async ({ page }) => {
|
||||
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)).toBeVisible()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliates landing — mobile @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading and primary CTA at narrow viewports', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByTestId('affiliate-hero-cta')).toBeVisible()
|
||||
})
|
||||
|
||||
test('program details collapse to a stacked definition list', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByTestId('affiliate-program-details-table')
|
||||
).toBeHidden()
|
||||
const detailsList = page
|
||||
.getByTestId('affiliate-program-details')
|
||||
.locator('dl')
|
||||
await expect(detailsList).toBeVisible()
|
||||
await expect(
|
||||
detailsList.getByText(FIRST_PROGRAM_DETAIL_LABEL)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
detailsList.getByText(FIRST_PROGRAM_DETAIL_VALUE)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('all major sections remain visible without horizontal overflow', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const id of SECTION_TESTIDS) {
|
||||
const section = page.getByTestId(id)
|
||||
await expect(section).toBeVisible()
|
||||
const box = await section.boundingBox()
|
||||
expect(box, `${id} bounding box`).not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="147" height="159" viewBox="0 0 147 159" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M116.437 118.915C116.712 117.983 116.863 117 116.863 115.967C116.863 110.25 112.252 105.615 106.564 105.615H60.4108C57.9301 105.64 55.9006 103.625 55.9006 101.131C55.9006 100.678 55.9759 100.25 56.0761 99.8468L68.504 56.3212C69.0302 54.4069 70.7841 52.9963 72.8387 52.9963L119.168 52.946C128.94 52.946 137.182 46.3214 139.664 37.2788L146.63 13.0223C146.854 12.1658 146.98 11.2338 146.98 10.3019C146.98 4.60938 142.395 0 136.733 0H80.6814C70.9594 0 62.7409 6.57416 60.2104 15.5159L55.4998 32.0647C54.9485 33.9539 53.2197 35.3392 51.1651 35.3392H37.7098C28.0631 35.3392 19.9198 41.7875 17.3139 50.6287L0.375936 110.098C0.125241 110.98 0 111.937 0 112.894C0 118.612 4.61042 123.247 10.2981 123.247H23.5278C26.0085 123.247 28.038 125.262 28.038 127.781C28.038 128.209 27.988 128.637 27.8627 129.04L23.1771 145.438C22.9515 146.32 22.8012 147.226 22.8012 148.158C22.8012 153.851 27.3866 158.461 33.0492 158.461L89.1253 158.409C98.8722 158.409 107.091 151.81 109.596 142.819L116.412 118.94L116.437 118.915Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="142" height="142" rx="33" fill="#211927"/>
|
||||
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="142" height="142" rx="33" fill="#F2FF59"/>
|
||||
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="148" height="159" viewBox="0 0 148 159" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M116.653 119.135C116.929 118.202 117.08 117.217 117.08 116.183C117.08 110.454 112.461 105.811 106.762 105.811H60.523C58.0377 105.836 56.0044 103.817 56.0044 101.319C56.0044 100.865 56.0798 100.436 56.1802 100.032L68.6312 56.4258C69.1584 54.508 70.9155 53.0947 72.9739 53.0947L119.389 53.0443C129.179 53.0443 137.437 46.4074 139.924 37.348L146.903 13.0464C147.127 12.1884 147.253 11.2547 147.253 10.321C147.253 4.61794 142.659 0 136.987 0H80.8312C71.0912 0 62.8574 6.58636 60.3222 15.5448L55.6028 32.1242C55.0505 34.017 53.3185 35.4049 51.2601 35.4049H37.7798C28.1152 35.4049 19.9568 41.8651 17.346 50.7227L0.376634 110.303C0.125474 111.186 0 112.145 0 113.104C0 118.832 4.61899 123.476 10.3173 123.476H23.5715C26.0568 123.476 28.0901 125.495 28.0901 128.018C28.0901 128.447 28.0399 128.876 27.9144 129.28L23.2202 145.708C22.9941 146.591 22.8435 147.5 22.8435 148.433C22.8435 154.137 27.4374 158.755 33.1106 158.755L89.2908 158.704C99.0558 158.704 107.29 152.092 109.8 143.084L116.628 119.16L116.653 119.135Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
42
apps/website/src/components/affiliates/AudienceSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
43
apps/website/src/components/affiliates/FooterCtaSection.vue
Normal 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>
|
||||
84
apps/website/src/components/affiliates/HeroSection.vue
Normal 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>
|
||||
55
apps/website/src/components/affiliates/HowItWorksSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
20
apps/website/src/components/affiliates/TrustBandSection.vue
Normal 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>
|
||||
6
apps/website/src/components/affiliates/affiliateFaqs.ts
Normal 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
|
||||
154
apps/website/src/components/affiliates/affiliateLanding.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
59
apps/website/src/components/affiliates/brandAssets.ts
Normal 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'
|
||||
}
|
||||
]
|
||||
33
apps/website/src/components/affiliates/programDetails.ts
Normal 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'
|
||||
}
|
||||
]
|
||||
@@ -9,12 +9,14 @@ import { brandButtonVariants } from './brandButton.variants'
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
variant,
|
||||
size,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
variant?: BrandButtonVariants['variant']
|
||||
size?: BrandButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
@@ -26,6 +28,7 @@ const {
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href
|
||||
:target
|
||||
:rel
|
||||
:class="cn(brandButtonVariants({ variant, size }), customClass)"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
|
||||
@@ -67,6 +67,10 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
label: t('nav.youtube', locale),
|
||||
href: externalLinks.youtube,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('footer.affiliateProgram', locale),
|
||||
href: routes.affiliates
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const baseRoutes = {
|
||||
demos: '/demos',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
affiliates: '/affiliates',
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models'
|
||||
@@ -32,6 +33,7 @@ type Routes = typeof baseRoutes
|
||||
//
|
||||
// termsOfService: legal-reviewed English-only document, same reasoning.
|
||||
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
|
||||
'affiliates',
|
||||
'affiliateTerms',
|
||||
'termsOfService'
|
||||
])
|
||||
@@ -48,6 +50,7 @@ export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
}
|
||||
|
||||
export const externalLinks = {
|
||||
affiliateApplicationForm: 'https://forms.gle/RS8L2ttcuGap4Q1v6',
|
||||
apiKeys: 'https://platform.comfy.org/profile/api-keys',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
cloud: 'https://cloud.comfy.org',
|
||||
|
||||
@@ -1775,6 +1775,10 @@ const translations = {
|
||||
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
|
||||
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
|
||||
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'footer.affiliateProgram': {
|
||||
en: 'Affiliate Program',
|
||||
'zh-CN': 'Affiliate Program'
|
||||
},
|
||||
'footer.location': {
|
||||
en: 'San Francisco, USA',
|
||||
'zh-CN': '美国旧金山'
|
||||
@@ -4418,6 +4422,315 @@ const translations = {
|
||||
'payment.failed.secondaryCta': {
|
||||
en: 'READ SUBSCRIPTION DOCS',
|
||||
'zh-CN': '查看订阅文档'
|
||||
},
|
||||
|
||||
// Affiliate landing page (/affiliates)
|
||||
// English-only copy; zh-CN values mirror en until a localized version lands.
|
||||
'affiliate-landing.page.title': {
|
||||
en: 'Comfy.org Affiliate Program — Become a Partner',
|
||||
'zh-CN': 'Comfy.org Affiliate Program — Become a Partner'
|
||||
},
|
||||
'affiliate-landing.page.description': {
|
||||
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
|
||||
'zh-CN':
|
||||
'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.'
|
||||
},
|
||||
'affiliate-landing.cta.apply': {
|
||||
en: 'Apply Now',
|
||||
'zh-CN': 'Apply Now'
|
||||
},
|
||||
'affiliate-landing.cta.applyAriaLabel': {
|
||||
en: 'Apply Now (opens in new tab)',
|
||||
'zh-CN': 'Apply Now (opens in new tab)'
|
||||
},
|
||||
|
||||
// Hero
|
||||
'affiliate-landing.hero.heading': {
|
||||
en: 'Become a Comfy Partner',
|
||||
'zh-CN': 'Become a Comfy Partner'
|
||||
},
|
||||
'affiliate-landing.hero.subheading': {
|
||||
en: 'Earn 30% Commission for 3 Months.',
|
||||
'zh-CN': 'Earn 30% Commission for 3 Months.'
|
||||
},
|
||||
'affiliate-landing.hero.body': {
|
||||
en: 'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:',
|
||||
'zh-CN':
|
||||
'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.0': {
|
||||
en: '30% recurring commission for 3 months',
|
||||
'zh-CN': '30% recurring commission for 3 months'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.1': {
|
||||
en: '60-day cookie window',
|
||||
'zh-CN': '60-day cookie window'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.2': {
|
||||
en: '$100 minimum payout',
|
||||
'zh-CN': '$100 minimum payout'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.3': {
|
||||
en: 'Monthly payouts',
|
||||
'zh-CN': 'Monthly payouts'
|
||||
},
|
||||
|
||||
// Trust band
|
||||
'affiliate-landing.trust.label': {
|
||||
en: 'Trusted by 2M+ creators worldwide',
|
||||
'zh-CN': 'Trusted by 2M+ creators worldwide'
|
||||
},
|
||||
|
||||
// How it works
|
||||
'affiliate-landing.how.heading': {
|
||||
en: 'How it works',
|
||||
'zh-CN': 'How it works'
|
||||
},
|
||||
'affiliate-landing.how.step.0.title': {
|
||||
en: 'Apply.',
|
||||
'zh-CN': 'Apply.'
|
||||
},
|
||||
'affiliate-landing.how.step.0.body': {
|
||||
en: 'Submit a quick form. Most applicants approved same day.',
|
||||
'zh-CN': 'Submit a quick form. Most applicants approved same day.'
|
||||
},
|
||||
'affiliate-landing.how.step.1.title': {
|
||||
en: 'Share.',
|
||||
'zh-CN': 'Share.'
|
||||
},
|
||||
'affiliate-landing.how.step.1.body': {
|
||||
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
|
||||
'zh-CN':
|
||||
'Get your unique tracking link. Share via content, social, email, however you reach your audience.'
|
||||
},
|
||||
'affiliate-landing.how.step.2.title': {
|
||||
en: 'Earn.',
|
||||
'zh-CN': 'Earn.'
|
||||
},
|
||||
'affiliate-landing.how.step.2.body': {
|
||||
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
|
||||
'zh-CN':
|
||||
'30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.'
|
||||
},
|
||||
|
||||
// Who we're looking for
|
||||
'affiliate-landing.audience.heading': {
|
||||
en: "Who we're looking for",
|
||||
'zh-CN': "Who we're looking for"
|
||||
},
|
||||
'affiliate-landing.audience.item.0': {
|
||||
en: 'ComfyUI tutorial creators and workflow builders',
|
||||
'zh-CN': 'ComfyUI tutorial creators and workflow builders'
|
||||
},
|
||||
'affiliate-landing.audience.item.1': {
|
||||
en: 'AI tool reviewers on YouTube, TikTok, blogs',
|
||||
'zh-CN': 'AI tool reviewers on YouTube, TikTok, blogs'
|
||||
},
|
||||
'affiliate-landing.audience.item.2': {
|
||||
en: 'Tech bloggers covering AI creative tools',
|
||||
'zh-CN': 'Tech bloggers covering AI creative tools'
|
||||
},
|
||||
'affiliate-landing.audience.item.3': {
|
||||
en: 'Newsletter operators in the AI/creative space',
|
||||
'zh-CN': 'Newsletter operators in the AI/creative space'
|
||||
},
|
||||
'affiliate-landing.audience.item.4': {
|
||||
en: 'Anyone with an audience interested in AI image, video, or 3D generation',
|
||||
'zh-CN':
|
||||
'Anyone with an audience interested in AI image, video, or 3D generation'
|
||||
},
|
||||
|
||||
// Program details
|
||||
'affiliate-landing.details.heading': {
|
||||
en: 'Program details',
|
||||
'zh-CN': 'Program details'
|
||||
},
|
||||
'affiliate-landing.details.headerLabel': {
|
||||
en: 'Detail',
|
||||
'zh-CN': 'Detail'
|
||||
},
|
||||
'affiliate-landing.details.headerValue': {
|
||||
en: 'Value',
|
||||
'zh-CN': 'Value'
|
||||
},
|
||||
'affiliate-landing.details.row.0.label': {
|
||||
en: 'Commission rate',
|
||||
'zh-CN': 'Commission rate'
|
||||
},
|
||||
'affiliate-landing.details.row.0.value': {
|
||||
en: '30% recurring',
|
||||
'zh-CN': '30% recurring'
|
||||
},
|
||||
'affiliate-landing.details.row.1.label': {
|
||||
en: 'Commission duration',
|
||||
'zh-CN': 'Commission duration'
|
||||
},
|
||||
'affiliate-landing.details.row.1.value': {
|
||||
en: '3 months',
|
||||
'zh-CN': '3 months'
|
||||
},
|
||||
'affiliate-landing.details.row.2.label': {
|
||||
en: 'Cookie window',
|
||||
'zh-CN': 'Cookie window'
|
||||
},
|
||||
'affiliate-landing.details.row.2.value': {
|
||||
en: '60 days',
|
||||
'zh-CN': '60 days'
|
||||
},
|
||||
'affiliate-landing.details.row.3.label': {
|
||||
en: 'Eligible products',
|
||||
'zh-CN': 'Eligible products'
|
||||
},
|
||||
'affiliate-landing.details.row.3.value': {
|
||||
en: 'Comfy Cloud paid subscription plans',
|
||||
'zh-CN': 'Comfy Cloud paid subscription plans'
|
||||
},
|
||||
'affiliate-landing.details.row.4.label': {
|
||||
en: 'Payouts',
|
||||
'zh-CN': 'Payouts'
|
||||
},
|
||||
'affiliate-landing.details.row.4.value': {
|
||||
en: 'Monthly, within first 10 business days',
|
||||
'zh-CN': 'Monthly, within first 10 business days'
|
||||
},
|
||||
'affiliate-landing.details.row.5.label': {
|
||||
en: 'Minimum payout',
|
||||
'zh-CN': 'Minimum payout'
|
||||
},
|
||||
'affiliate-landing.details.row.5.value': {
|
||||
en: '$100',
|
||||
'zh-CN': '$100'
|
||||
},
|
||||
|
||||
// Brand assets
|
||||
'affiliate-landing.assets.heading': {
|
||||
en: 'Brand assets',
|
||||
'zh-CN': 'Brand assets'
|
||||
},
|
||||
'affiliate-landing.assets.subheading': {
|
||||
en: 'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.'
|
||||
},
|
||||
'affiliate-landing.assets.downloadLabel': {
|
||||
en: 'Download',
|
||||
'zh-CN': 'Download'
|
||||
},
|
||||
'affiliate-landing.assets.tile.logo-horizontal.title': {
|
||||
en: 'Comfy logo (horizontal)',
|
||||
'zh-CN': 'Comfy logo (horizontal)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.logomark.title': {
|
||||
en: 'Comfy logomark',
|
||||
'zh-CN': 'Comfy logomark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.comfy-full-logo-yellow.title': {
|
||||
en: 'Comfy full logo (yellow)',
|
||||
'zh-CN': 'Comfy full logo (yellow)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.comfy-full-logo-ink.title': {
|
||||
en: 'Comfy full logo (ink)',
|
||||
'zh-CN': 'Comfy full logo (ink)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.amplified-logo-mark.title': {
|
||||
en: 'Amplified logo mark',
|
||||
'zh-CN': 'Amplified logo mark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.dimensional-logo-mark.title': {
|
||||
en: 'Dimensional logo mark',
|
||||
'zh-CN': 'Dimensional logo mark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.color-combo-yellow.title': {
|
||||
en: 'Color combo (yellow)',
|
||||
'zh-CN': 'Color combo (yellow)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.color-combo-ink.title': {
|
||||
en: 'Color combo (ink)',
|
||||
'zh-CN': 'Color combo (ink)'
|
||||
},
|
||||
|
||||
// FAQ — keys follow the FAQSection contract: <prefix>.<n>.q / <prefix>.<n>.a, 1-indexed
|
||||
'affiliate-landing.faq.heading': {
|
||||
en: 'Questions',
|
||||
'zh-CN': 'Questions'
|
||||
},
|
||||
'affiliate-landing.faq.1.q': {
|
||||
en: 'How do I track my referrals?',
|
||||
'zh-CN': 'How do I track my referrals?'
|
||||
},
|
||||
'affiliate-landing.faq.1.a': {
|
||||
en: 'Real-time dashboard via our partner portal.',
|
||||
'zh-CN': 'Real-time dashboard via our partner portal.'
|
||||
},
|
||||
'affiliate-landing.faq.2.q': {
|
||||
en: 'What plans qualify?',
|
||||
'zh-CN': 'What plans qualify?'
|
||||
},
|
||||
'affiliate-landing.faq.2.a': {
|
||||
en: 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).',
|
||||
'zh-CN': 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).'
|
||||
},
|
||||
'affiliate-landing.faq.3.q': {
|
||||
en: 'How long does approval take?',
|
||||
'zh-CN': 'How long does approval take?'
|
||||
},
|
||||
'affiliate-landing.faq.3.a': {
|
||||
en: 'Most applications approved within 24 hours.',
|
||||
'zh-CN': 'Most applications approved within 24 hours.'
|
||||
},
|
||||
'affiliate-landing.faq.4.q': {
|
||||
en: 'When do I get paid?',
|
||||
'zh-CN': 'When do I get paid?'
|
||||
},
|
||||
'affiliate-landing.faq.4.a': {
|
||||
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
|
||||
'zh-CN':
|
||||
'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.'
|
||||
},
|
||||
'affiliate-landing.faq.5.q': {
|
||||
en: 'What happens if my referral upgrades or downgrades?',
|
||||
'zh-CN': 'What happens if my referral upgrades or downgrades?'
|
||||
},
|
||||
'affiliate-landing.faq.5.a': {
|
||||
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
|
||||
'zh-CN':
|
||||
'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.'
|
||||
},
|
||||
'affiliate-landing.faq.6.q': {
|
||||
en: 'Can I use coupon codes?',
|
||||
'zh-CN': 'Can I use coupon codes?'
|
||||
},
|
||||
'affiliate-landing.faq.6.a': {
|
||||
en: 'Yes. We support both tracking links and unique coupon codes.',
|
||||
'zh-CN': 'Yes. We support both tracking links and unique coupon codes.'
|
||||
},
|
||||
'affiliate-landing.faq.7.q': {
|
||||
en: 'What if my referral uses an ad blocker?',
|
||||
'zh-CN': 'What if my referral uses an ad blocker?'
|
||||
},
|
||||
'affiliate-landing.faq.7.a': {
|
||||
en: 'We use server-side tracking, so conversions are tracked regardless.',
|
||||
'zh-CN':
|
||||
'We use server-side tracking, so conversions are tracked regardless.'
|
||||
},
|
||||
'affiliate-landing.faq.8.q': {
|
||||
en: 'What assets do you provide?',
|
||||
'zh-CN': 'What assets do you provide?'
|
||||
},
|
||||
'affiliate-landing.faq.8.a': {
|
||||
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.'
|
||||
},
|
||||
|
||||
// Footer CTA
|
||||
'affiliate-landing.footerCta.heading': {
|
||||
en: 'Ready to start earning?',
|
||||
'zh-CN': 'Ready to start earning?'
|
||||
},
|
||||
'affiliate-landing.footerCta.termsLink': {
|
||||
en: 'Read the affiliate program terms',
|
||||
'zh-CN': 'Read the affiliate program terms'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
65
apps/website/src/pages/affiliates/index.astro
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import AudienceSection from '../../components/affiliates/AudienceSection.vue'
|
||||
import BrandAssetsSection from '../../components/affiliates/BrandAssetsSection.vue'
|
||||
import FooterCtaSection from '../../components/affiliates/FooterCtaSection.vue'
|
||||
import HeroSection from '../../components/affiliates/HeroSection.vue'
|
||||
import HowItWorksSection from '../../components/affiliates/HowItWorksSection.vue'
|
||||
import ProgramDetailsSection from '../../components/affiliates/ProgramDetailsSection.vue'
|
||||
import TrustBandSection from '../../components/affiliates/TrustBandSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_HEADING_KEY,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from '../../components/affiliates/affiliateFaqs'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale: Locale =
|
||||
Astro.currentLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => {
|
||||
const n = i + 1
|
||||
return {
|
||||
'@type': 'Question',
|
||||
name: t(`${AFFILIATE_FAQ_PREFIX}.${n}.q` as TranslationKey, locale),
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: t(`${AFFILIATE_FAQ_PREFIX}.${n}.a` as TranslationKey, locale)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('affiliate-landing.page.title', locale)}
|
||||
description={t('affiliate-landing.page.description', locale)}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<HeroSection locale={locale} client:load />
|
||||
<TrustBandSection locale={locale} />
|
||||
<HowItWorksSection locale={locale} />
|
||||
<AudienceSection locale={locale} />
|
||||
<ProgramDetailsSection locale={locale} />
|
||||
<BrandAssetsSection locale={locale} />
|
||||
<FAQSection
|
||||
locale={locale}
|
||||
headingKey={AFFILIATE_FAQ_HEADING_KEY}
|
||||
faqPrefix={AFFILIATE_FAQ_PREFIX}
|
||||
faqCount={AFFILIATE_FAQ_COUNT}
|
||||
client:load
|
||||
/>
|
||||
<FooterCtaSection locale={locale} client:load />
|
||||
</BaseLayout>
|
||||
@@ -1,7 +1,6 @@
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export type VideoFormat = 'webm' | 'mp4'
|
||||
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export type VideoSource = {
|
||||
src: string
|
||||
type: `video/${VideoFormat}`
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { KnipConfig } from 'knip'
|
||||
|
||||
const config: KnipConfig = {
|
||||
treatConfigHintsAsErrors: true,
|
||||
treatTagHintsAsErrors: true,
|
||||
workspaces: {
|
||||
'.': {
|
||||
entry: [
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
@theme {
|
||||
--shadow-interface: var(--interface-panel-box-shadow);
|
||||
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
|
||||
|
||||
--text-2xs: 0.625rem;
|
||||
--text-2xs--line-height: calc(1 / 0.625);
|
||||
@@ -62,6 +63,9 @@
|
||||
--color-ocean-600: #2f687a;
|
||||
--color-ocean-900: #253236;
|
||||
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-canvas: #c2bfb9;
|
||||
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
|
||||
6
packages/design-system/src/icons/bytedance-mono.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
|
||||
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
|
||||
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
|
||||
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
packages/design-system/src/icons/comfy-logo.svg
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
6
packages/design-system/src/icons/gemini-mono.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
372
pnpm-lock.yaml
generated
@@ -259,8 +259,8 @@ catalogs:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
knip:
|
||||
specifier: ^6.14.1
|
||||
version: 6.14.2
|
||||
specifier: ^6.15.0
|
||||
version: 6.15.0
|
||||
lenis:
|
||||
specifier: ^1.3.21
|
||||
version: 1.3.21
|
||||
@@ -762,7 +762,7 @@ importers:
|
||||
version: 27.4.0
|
||||
knip:
|
||||
specifier: 'catalog:'
|
||||
version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
version: 6.15.0
|
||||
lint-staged:
|
||||
specifier: 'catalog:'
|
||||
version: 16.4.0
|
||||
@@ -2515,244 +2515,239 @@ packages:
|
||||
'@oslojs/encoding@1.1.0':
|
||||
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
||||
|
||||
'@oxc-parser/binding-android-arm-eabi@0.130.0':
|
||||
resolution: {integrity: sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==}
|
||||
'@oxc-parser/binding-android-arm-eabi@0.133.0':
|
||||
resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxc-parser/binding-android-arm64@0.130.0':
|
||||
resolution: {integrity: sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==}
|
||||
'@oxc-parser/binding-android-arm64@0.133.0':
|
||||
resolution: {integrity: sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxc-parser/binding-darwin-arm64@0.130.0':
|
||||
resolution: {integrity: sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==}
|
||||
'@oxc-parser/binding-darwin-arm64@0.133.0':
|
||||
resolution: {integrity: sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-parser/binding-darwin-x64@0.130.0':
|
||||
resolution: {integrity: sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==}
|
||||
'@oxc-parser/binding-darwin-x64@0.133.0':
|
||||
resolution: {integrity: sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-parser/binding-freebsd-x64@0.130.0':
|
||||
resolution: {integrity: sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==}
|
||||
'@oxc-parser/binding-freebsd-x64@0.133.0':
|
||||
resolution: {integrity: sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf@0.130.0':
|
||||
resolution: {integrity: sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==}
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf@0.133.0':
|
||||
resolution: {integrity: sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-arm-musleabihf@0.130.0':
|
||||
resolution: {integrity: sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==}
|
||||
'@oxc-parser/binding-linux-arm-musleabihf@0.133.0':
|
||||
resolution: {integrity: sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-gnu@0.130.0':
|
||||
resolution: {integrity: sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==}
|
||||
'@oxc-parser/binding-linux-arm64-gnu@0.133.0':
|
||||
resolution: {integrity: sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-musl@0.130.0':
|
||||
resolution: {integrity: sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==}
|
||||
'@oxc-parser/binding-linux-arm64-musl@0.133.0':
|
||||
resolution: {integrity: sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-parser/binding-linux-ppc64-gnu@0.130.0':
|
||||
resolution: {integrity: sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==}
|
||||
'@oxc-parser/binding-linux-ppc64-gnu@0.133.0':
|
||||
resolution: {integrity: sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-gnu@0.130.0':
|
||||
resolution: {integrity: sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==}
|
||||
'@oxc-parser/binding-linux-riscv64-gnu@0.133.0':
|
||||
resolution: {integrity: sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-musl@0.130.0':
|
||||
resolution: {integrity: sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==}
|
||||
'@oxc-parser/binding-linux-riscv64-musl@0.133.0':
|
||||
resolution: {integrity: sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-parser/binding-linux-s390x-gnu@0.130.0':
|
||||
resolution: {integrity: sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==}
|
||||
'@oxc-parser/binding-linux-s390x-gnu@0.133.0':
|
||||
resolution: {integrity: sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-x64-gnu@0.130.0':
|
||||
resolution: {integrity: sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==}
|
||||
'@oxc-parser/binding-linux-x64-gnu@0.133.0':
|
||||
resolution: {integrity: sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-x64-musl@0.130.0':
|
||||
resolution: {integrity: sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==}
|
||||
'@oxc-parser/binding-linux-x64-musl@0.133.0':
|
||||
resolution: {integrity: sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-parser/binding-openharmony-arm64@0.130.0':
|
||||
resolution: {integrity: sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==}
|
||||
'@oxc-parser/binding-openharmony-arm64@0.133.0':
|
||||
resolution: {integrity: sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxc-parser/binding-wasm32-wasi@0.130.0':
|
||||
resolution: {integrity: sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==}
|
||||
'@oxc-parser/binding-wasm32-wasi@0.133.0':
|
||||
resolution: {integrity: sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@oxc-parser/binding-win32-arm64-msvc@0.130.0':
|
||||
resolution: {integrity: sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==}
|
||||
'@oxc-parser/binding-win32-arm64-msvc@0.133.0':
|
||||
resolution: {integrity: sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-parser/binding-win32-ia32-msvc@0.130.0':
|
||||
resolution: {integrity: sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==}
|
||||
'@oxc-parser/binding-win32-ia32-msvc@0.133.0':
|
||||
resolution: {integrity: sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-parser/binding-win32-x64-msvc@0.130.0':
|
||||
resolution: {integrity: sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==}
|
||||
'@oxc-parser/binding-win32-x64-msvc@0.133.0':
|
||||
resolution: {integrity: sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-project/types@0.130.0':
|
||||
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
|
||||
|
||||
'@oxc-project/types@0.132.0':
|
||||
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
|
||||
resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==}
|
||||
'@oxc-project/types@0.133.0':
|
||||
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.20.0':
|
||||
resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.19.1':
|
||||
resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==}
|
||||
'@oxc-resolver/binding-android-arm64@11.20.0':
|
||||
resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.19.1':
|
||||
resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==}
|
||||
'@oxc-resolver/binding-darwin-arm64@11.20.0':
|
||||
resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.19.1':
|
||||
resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==}
|
||||
'@oxc-resolver/binding-darwin-x64@11.20.0':
|
||||
resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.19.1':
|
||||
resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==}
|
||||
'@oxc-resolver/binding-freebsd-x64@11.20.0':
|
||||
resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
|
||||
resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==}
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0':
|
||||
resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
|
||||
resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==}
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.20.0':
|
||||
resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
|
||||
resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==}
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.20.0':
|
||||
resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
|
||||
resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==}
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.20.0':
|
||||
resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
|
||||
resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==}
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.20.0':
|
||||
resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
|
||||
resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==}
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.20.0':
|
||||
resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
|
||||
resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==}
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.20.0':
|
||||
resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
|
||||
resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==}
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.20.0':
|
||||
resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
|
||||
resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==}
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.20.0':
|
||||
resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
|
||||
resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==}
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.20.0':
|
||||
resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
|
||||
resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==}
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.20.0':
|
||||
resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.19.1':
|
||||
resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==}
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.20.0':
|
||||
resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
|
||||
resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==}
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.20.0':
|
||||
resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
|
||||
resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
|
||||
resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==}
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
|
||||
resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -6347,8 +6342,8 @@ packages:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
knip@6.14.2:
|
||||
resolution: {integrity: sha512-Vg3JhIINjZew1I7qAFI4UHemW1mc4azP/BxJvsq9eGDfxpGO7oVCuD/bsWkog9TO/ZwwJeAeOMFZ1kd9jnY9+Q==}
|
||||
knip@6.15.0:
|
||||
resolution: {integrity: sha512-uBaKFEGcu/HG4EY2gWFBMr+fBF43Jftoc2riJX51TKME1Z46C8UQIbNEusenYbEWihphxe2PY0Kns0yPvPYz4A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -6982,12 +6977,12 @@ packages:
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
oxc-parser@0.130.0:
|
||||
resolution: {integrity: sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==}
|
||||
oxc-parser@0.133.0:
|
||||
resolution: {integrity: sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
oxc-resolver@11.19.1:
|
||||
resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
|
||||
oxc-resolver@11.20.0:
|
||||
resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==}
|
||||
|
||||
oxfmt@0.44.0:
|
||||
resolution: {integrity: sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==}
|
||||
@@ -10554,137 +10549,133 @@ snapshots:
|
||||
|
||||
'@oslojs/encoding@1.1.0': {}
|
||||
|
||||
'@oxc-parser/binding-android-arm-eabi@0.130.0':
|
||||
'@oxc-parser/binding-android-arm-eabi@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-android-arm64@0.130.0':
|
||||
'@oxc-parser/binding-android-arm64@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-darwin-arm64@0.130.0':
|
||||
'@oxc-parser/binding-darwin-arm64@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-darwin-x64@0.130.0':
|
||||
'@oxc-parser/binding-darwin-x64@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-freebsd-x64@0.130.0':
|
||||
'@oxc-parser/binding-freebsd-x64@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf@0.130.0':
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm-musleabihf@0.130.0':
|
||||
'@oxc-parser/binding-linux-arm-musleabihf@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-gnu@0.130.0':
|
||||
'@oxc-parser/binding-linux-arm64-gnu@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-musl@0.130.0':
|
||||
'@oxc-parser/binding-linux-arm64-musl@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-ppc64-gnu@0.130.0':
|
||||
'@oxc-parser/binding-linux-ppc64-gnu@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-gnu@0.130.0':
|
||||
'@oxc-parser/binding-linux-riscv64-gnu@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-musl@0.130.0':
|
||||
'@oxc-parser/binding-linux-riscv64-musl@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-s390x-gnu@0.130.0':
|
||||
'@oxc-parser/binding-linux-s390x-gnu@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-x64-gnu@0.130.0':
|
||||
'@oxc-parser/binding-linux-x64-gnu@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-x64-musl@0.130.0':
|
||||
'@oxc-parser/binding-linux-x64-musl@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-openharmony-arm64@0.130.0':
|
||||
'@oxc-parser/binding-openharmony-arm64@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-wasm32-wasi@0.130.0':
|
||||
'@oxc-parser/binding-wasm32-wasi@0.133.0':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-win32-arm64-msvc@0.130.0':
|
||||
'@oxc-parser/binding-win32-arm64-msvc@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-win32-ia32-msvc@0.130.0':
|
||||
'@oxc-parser/binding-win32-ia32-msvc@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-win32-x64-msvc@0.130.0':
|
||||
'@oxc-parser/binding-win32-x64-msvc@0.133.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-project/types@0.130.0': {}
|
||||
|
||||
'@oxc-project/types@0.132.0': {}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
|
||||
'@oxc-project/types@0.133.0': {}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.19.1':
|
||||
'@oxc-resolver/binding-android-arm64@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.19.1':
|
||||
'@oxc-resolver/binding-darwin-arm64@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.19.1':
|
||||
'@oxc-resolver/binding-darwin-x64@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.19.1':
|
||||
'@oxc-resolver/binding-freebsd-x64@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.20.0':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.44.0':
|
||||
@@ -14478,15 +14469,15 @@ snapshots:
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
knip@6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
|
||||
knip@6.15.0:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
formatly: 0.3.0
|
||||
get-tsconfig: 4.14.0
|
||||
jiti: 2.7.0
|
||||
minimist: 1.2.8
|
||||
oxc-parser: 0.130.0
|
||||
oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
oxc-parser: 0.133.0
|
||||
oxc-resolver: 11.20.0
|
||||
picomatch: 4.0.4
|
||||
smol-toml: 1.6.1
|
||||
strip-json-comments: 5.0.3
|
||||
@@ -14494,9 +14485,6 @@ snapshots:
|
||||
unbash: 3.0.0
|
||||
yaml: 2.9.0
|
||||
zod: 4.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
|
||||
known-css-properties@0.37.0: {}
|
||||
|
||||
@@ -15295,56 +15283,52 @@ snapshots:
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
oxc-parser@0.130.0:
|
||||
oxc-parser@0.133.0:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.130.0
|
||||
'@oxc-project/types': 0.133.0
|
||||
optionalDependencies:
|
||||
'@oxc-parser/binding-android-arm-eabi': 0.130.0
|
||||
'@oxc-parser/binding-android-arm64': 0.130.0
|
||||
'@oxc-parser/binding-darwin-arm64': 0.130.0
|
||||
'@oxc-parser/binding-darwin-x64': 0.130.0
|
||||
'@oxc-parser/binding-freebsd-x64': 0.130.0
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf': 0.130.0
|
||||
'@oxc-parser/binding-linux-arm-musleabihf': 0.130.0
|
||||
'@oxc-parser/binding-linux-arm64-gnu': 0.130.0
|
||||
'@oxc-parser/binding-linux-arm64-musl': 0.130.0
|
||||
'@oxc-parser/binding-linux-ppc64-gnu': 0.130.0
|
||||
'@oxc-parser/binding-linux-riscv64-gnu': 0.130.0
|
||||
'@oxc-parser/binding-linux-riscv64-musl': 0.130.0
|
||||
'@oxc-parser/binding-linux-s390x-gnu': 0.130.0
|
||||
'@oxc-parser/binding-linux-x64-gnu': 0.130.0
|
||||
'@oxc-parser/binding-linux-x64-musl': 0.130.0
|
||||
'@oxc-parser/binding-openharmony-arm64': 0.130.0
|
||||
'@oxc-parser/binding-wasm32-wasi': 0.130.0
|
||||
'@oxc-parser/binding-win32-arm64-msvc': 0.130.0
|
||||
'@oxc-parser/binding-win32-ia32-msvc': 0.130.0
|
||||
'@oxc-parser/binding-win32-x64-msvc': 0.130.0
|
||||
'@oxc-parser/binding-android-arm-eabi': 0.133.0
|
||||
'@oxc-parser/binding-android-arm64': 0.133.0
|
||||
'@oxc-parser/binding-darwin-arm64': 0.133.0
|
||||
'@oxc-parser/binding-darwin-x64': 0.133.0
|
||||
'@oxc-parser/binding-freebsd-x64': 0.133.0
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf': 0.133.0
|
||||
'@oxc-parser/binding-linux-arm-musleabihf': 0.133.0
|
||||
'@oxc-parser/binding-linux-arm64-gnu': 0.133.0
|
||||
'@oxc-parser/binding-linux-arm64-musl': 0.133.0
|
||||
'@oxc-parser/binding-linux-ppc64-gnu': 0.133.0
|
||||
'@oxc-parser/binding-linux-riscv64-gnu': 0.133.0
|
||||
'@oxc-parser/binding-linux-riscv64-musl': 0.133.0
|
||||
'@oxc-parser/binding-linux-s390x-gnu': 0.133.0
|
||||
'@oxc-parser/binding-linux-x64-gnu': 0.133.0
|
||||
'@oxc-parser/binding-linux-x64-musl': 0.133.0
|
||||
'@oxc-parser/binding-openharmony-arm64': 0.133.0
|
||||
'@oxc-parser/binding-wasm32-wasi': 0.133.0
|
||||
'@oxc-parser/binding-win32-arm64-msvc': 0.133.0
|
||||
'@oxc-parser/binding-win32-ia32-msvc': 0.133.0
|
||||
'@oxc-parser/binding-win32-x64-msvc': 0.133.0
|
||||
|
||||
oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
|
||||
oxc-resolver@11.20.0:
|
||||
optionalDependencies:
|
||||
'@oxc-resolver/binding-android-arm-eabi': 11.19.1
|
||||
'@oxc-resolver/binding-android-arm64': 11.19.1
|
||||
'@oxc-resolver/binding-darwin-arm64': 11.19.1
|
||||
'@oxc-resolver/binding-darwin-x64': 11.19.1
|
||||
'@oxc-resolver/binding-freebsd-x64': 11.19.1
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1
|
||||
'@oxc-resolver/binding-linux-arm64-gnu': 11.19.1
|
||||
'@oxc-resolver/binding-linux-arm64-musl': 11.19.1
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1
|
||||
'@oxc-resolver/binding-linux-riscv64-musl': 11.19.1
|
||||
'@oxc-resolver/binding-linux-s390x-gnu': 11.19.1
|
||||
'@oxc-resolver/binding-linux-x64-gnu': 11.19.1
|
||||
'@oxc-resolver/binding-linux-x64-musl': 11.19.1
|
||||
'@oxc-resolver/binding-openharmony-arm64': 11.19.1
|
||||
'@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
'@oxc-resolver/binding-win32-arm64-msvc': 11.19.1
|
||||
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
'@oxc-resolver/binding-android-arm-eabi': 11.20.0
|
||||
'@oxc-resolver/binding-android-arm64': 11.20.0
|
||||
'@oxc-resolver/binding-darwin-arm64': 11.20.0
|
||||
'@oxc-resolver/binding-darwin-x64': 11.20.0
|
||||
'@oxc-resolver/binding-freebsd-x64': 11.20.0
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0
|
||||
'@oxc-resolver/binding-linux-arm64-gnu': 11.20.0
|
||||
'@oxc-resolver/binding-linux-arm64-musl': 11.20.0
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0
|
||||
'@oxc-resolver/binding-linux-riscv64-musl': 11.20.0
|
||||
'@oxc-resolver/binding-linux-s390x-gnu': 11.20.0
|
||||
'@oxc-resolver/binding-linux-x64-gnu': 11.20.0
|
||||
'@oxc-resolver/binding-linux-x64-musl': 11.20.0
|
||||
'@oxc-resolver/binding-openharmony-arm64': 11.20.0
|
||||
'@oxc-resolver/binding-wasm32-wasi': 11.20.0
|
||||
'@oxc-resolver/binding-win32-arm64-msvc': 11.20.0
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.20.0
|
||||
|
||||
oxfmt@0.44.0:
|
||||
dependencies:
|
||||
|
||||
@@ -93,7 +93,7 @@ catalog:
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
knip: ^6.14.1
|
||||
knip: ^6.15.0
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
|
||||
@@ -67,6 +67,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -140,10 +141,12 @@ function closeDialog() {
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
const node = litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
const node = withNodeAddSource('search_modal', () =>
|
||||
litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
)
|
||||
)
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
@@ -155,8 +156,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
const graphNode = useLitegraphService().addNodeOnGraph(
|
||||
provider.nodeDef
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
|
||||
@@ -189,6 +189,7 @@ import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import {
|
||||
DEFAULT_GROUPING_ID,
|
||||
@@ -321,8 +322,11 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
}
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue'
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -183,8 +184,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
await nodeBookmarkStore.addBookmark(nodePath)
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -26,9 +27,20 @@ const mockDialogService = vi.hoisted(() => ({
|
||||
confirm: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
|
||||
const knownAuthErrorCodes = new Set([
|
||||
'auth/invalid-credential',
|
||||
'auth/email-already-in-use'
|
||||
])
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, values?: { workflow?: string }) =>
|
||||
values?.workflow ? `${key}:${values.workflow}` : key
|
||||
values?.workflow ? `${key}:${values.workflow}` : key,
|
||||
st: (key: string, fallback: string) => {
|
||||
const code = key.replace('auth.errors.', '')
|
||||
return knownAuthErrorCodes.has(code) ? key : fallback
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -72,7 +84,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
|
||||
action: (...args: TArgs) => Promise<TReturn> | TReturn
|
||||
) => action,
|
||||
toastErrorHandler: vi.fn()
|
||||
toastErrorHandler: mockToastErrorHandler
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -193,3 +205,46 @@ describe('useAuthActions.logout', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthActions.reportError', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the friendly message for a known Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.auth/invalid-credential'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the generic fallback for an unknown Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.generic'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates non-Firebase errors to toastErrorHandler', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
const networkError = new TypeError('Failed to fetch')
|
||||
|
||||
reportError(networkError)
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ref } from 'vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { st, t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -47,6 +47,12 @@ export const useAuthActions = () => {
|
||||
email: 'support@comfy.org'
|
||||
})
|
||||
})
|
||||
} else if (error instanceof FirebaseError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: st(`auth.errors.${error.code}`, t('auth.errors.generic'))
|
||||
})
|
||||
} else {
|
||||
toastErrorHandler(error)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { markRaw } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
LGraphNode,
|
||||
Reroute
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { createMockSubgraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Mock the app module
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
selected_nodes: null
|
||||
}
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selected_nodes: null as Record<string, LGraphNode> | null
|
||||
}
|
||||
}))
|
||||
|
||||
// canvasStore transitively imports the app singleton; stub it so the real
|
||||
// ComfyApp module never loads during these unit tests.
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
// Mock the litegraph module
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
@@ -30,6 +34,27 @@ vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Real LGraphNode instances so the production isLGraphNode (instanceof) guard runs
|
||||
// unmodified — the node accessors filter selectedItems with the real predicate.
|
||||
const makeNode = (mode: LGraphEventMode, id = 1): LGraphNode => {
|
||||
const node = new LGraphNode('Test')
|
||||
node.id = id
|
||||
node.mode = mode
|
||||
return node
|
||||
}
|
||||
|
||||
const makeSubgraphNode = (
|
||||
children: LGraphNode[],
|
||||
overrides: { id?: number; mode?: LGraphEventMode } = {}
|
||||
): LGraphNode =>
|
||||
Object.assign(
|
||||
makeNode(overrides.mode ?? LGraphEventMode.ALWAYS, overrides.id ?? 1),
|
||||
{
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: { nodes: children }
|
||||
}
|
||||
)
|
||||
|
||||
// Mock Positionable objects
|
||||
|
||||
class MockNode implements Positionable {
|
||||
@@ -77,16 +102,22 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
mockApp.canvas.selected_nodes = null
|
||||
|
||||
// Mock canvas with selectedItems Set
|
||||
mockCanvas = {
|
||||
// markRaw so the spied getter's return is not reactive-wrapped by the Pinia
|
||||
// store proxy — production reads a shallowRef, so nodes stay raw references.
|
||||
mockCanvas = markRaw({
|
||||
selectedItems: new Set<Positionable>()
|
||||
}
|
||||
})
|
||||
|
||||
// Mock getCanvas to return our mock canvas
|
||||
// getSelectableItems reads getCanvas(); the node accessors read canvasStore.canvas.
|
||||
// Point both at the same mock canvas.
|
||||
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(
|
||||
mockCanvas as ReturnType<typeof canvasStore.getCanvas>
|
||||
)
|
||||
vi.spyOn(canvasStore, 'canvas', 'get').mockReturnValue(
|
||||
mockCanvas as ReturnType<typeof canvasStore.getCanvas>
|
||||
)
|
||||
})
|
||||
|
||||
describe('isIgnoredItem', () => {
|
||||
@@ -207,23 +238,24 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
describe('node-specific methods', () => {
|
||||
it('getSelectedNodes should return only LGraphNode instances', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
const node1 = makeNode(LGraphEventMode.ALWAYS, 1)
|
||||
const node2 = makeNode(LGraphEventMode.NEVER, 2)
|
||||
const reroute = new MockReroute()
|
||||
|
||||
// Mock app.canvas.selected_nodes
|
||||
app.canvas.selected_nodes = { '0': node1, '1': node2 }
|
||||
// The non-node (reroute) must be filtered out by isLGraphNode.
|
||||
mockCanvas.selectedItems = new Set([node1, reroute, node2])
|
||||
|
||||
const selectedNodes = getSelectedNodes()
|
||||
expect(selectedNodes).toHaveLength(2)
|
||||
expect(selectedNodes[0]).toBe(node1)
|
||||
expect(selectedNodes[1]).toBe(node2)
|
||||
expect(selectedNodes).not.toContain(reroute)
|
||||
})
|
||||
|
||||
it('getSelectedNodes should return empty array when no nodes selected', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
|
||||
// @ts-expect-error - Testing null case
|
||||
app.canvas.selected_nodes = null
|
||||
mockCanvas.selectedItems = new Set()
|
||||
|
||||
const selectedNodes = getSelectedNodes()
|
||||
expect(selectedNodes).toHaveLength(0)
|
||||
@@ -231,10 +263,10 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
|
||||
it('toggleSelectedNodesMode should toggle node modes correctly', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
const node1 = makeNode(LGraphEventMode.ALWAYS, 1)
|
||||
const node2 = makeNode(LGraphEventMode.NEVER, 2)
|
||||
|
||||
app.canvas.selected_nodes = { '0': node1, '1': node2 }
|
||||
mockCanvas.selectedItems = new Set([node1, node2])
|
||||
|
||||
// Toggle to NEVER mode
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
@@ -247,9 +279,9 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
|
||||
it('toggleSelectedNodesMode should set mode to ALWAYS when already in target mode', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const node = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const node = makeNode(LGraphEventMode.BYPASS, 1)
|
||||
|
||||
app.canvas.selected_nodes = { '0': node }
|
||||
mockCanvas.selectedItems = new Set([node])
|
||||
|
||||
// Toggle to BYPASS mode (node is already BYPASS)
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
@@ -260,20 +292,20 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
|
||||
it('areAllSelectedNodesInMode returns true when every selected node matches', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
const node1 = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const node2 = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const node1 = makeNode(LGraphEventMode.BYPASS, 1)
|
||||
const node2 = makeNode(LGraphEventMode.BYPASS, 2)
|
||||
|
||||
app.canvas.selected_nodes = { '0': node1, '1': node2 }
|
||||
mockCanvas.selectedItems = new Set([node1, node2])
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns false on mixed selection', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
const bypassed = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const active = { id: 2, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const bypassed = makeNode(LGraphEventMode.BYPASS, 1)
|
||||
const active = makeNode(LGraphEventMode.ALWAYS, 2)
|
||||
|
||||
app.canvas.selected_nodes = { '0': bypassed, '1': active }
|
||||
mockCanvas.selectedItems = new Set([bypassed, active])
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
|
||||
})
|
||||
@@ -281,36 +313,36 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
it('areAllSelectedNodesInMode returns false for empty selection', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
|
||||
app.canvas.selected_nodes = {}
|
||||
mockCanvas.selectedItems = new Set()
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('getSelectedNodes should include nodes from subgraphs', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
|
||||
const regularNode = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
const subNode1 = makeNode(LGraphEventMode.ALWAYS, 11)
|
||||
const subNode2 = makeNode(LGraphEventMode.NEVER, 12)
|
||||
const subgraphNode = makeSubgraphNode([subNode1, subNode2])
|
||||
const regularNode = makeNode(LGraphEventMode.NEVER, 2)
|
||||
|
||||
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
|
||||
mockCanvas.selectedItems = new Set([subgraphNode, regularNode])
|
||||
|
||||
const selectedNodes = getSelectedNodes()
|
||||
expect(selectedNodes).toHaveLength(4) // subgraphNode + 2 sub nodes + regularNode
|
||||
expect(selectedNodes).toContainEqual(subgraphNode)
|
||||
expect(selectedNodes).toContainEqual(regularNode)
|
||||
expect(selectedNodes).toContainEqual(subNode1)
|
||||
expect(selectedNodes).toContainEqual(subNode2)
|
||||
expect(selectedNodes).toContain(subgraphNode)
|
||||
expect(selectedNodes).toContain(regularNode)
|
||||
expect(selectedNodes).toContain(subNode1)
|
||||
expect(selectedNodes).toContain(subNode2)
|
||||
})
|
||||
|
||||
it('toggleSelectedNodesMode should not apply state to subgraph children', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
|
||||
const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const subNode1 = makeNode(LGraphEventMode.ALWAYS, 11)
|
||||
const subNode2 = makeNode(LGraphEventMode.NEVER, 12)
|
||||
const subgraphNode = makeSubgraphNode([subNode1, subNode2])
|
||||
const regularNode = makeNode(LGraphEventMode.BYPASS, 2)
|
||||
|
||||
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
|
||||
mockCanvas.selectedItems = new Set([subgraphNode, regularNode])
|
||||
|
||||
// Toggle to NEVER mode
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
@@ -328,14 +360,14 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
|
||||
it('toggleSelectedNodesMode should toggle to ALWAYS when subgraph is already in target mode', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const subgraphNode = createMockSubgraphNode([subNode1, subNode2], {
|
||||
const subNode1 = makeNode(LGraphEventMode.ALWAYS, 11)
|
||||
const subNode2 = makeNode(LGraphEventMode.BYPASS, 12)
|
||||
const subgraphNode = makeSubgraphNode([subNode1, subNode2], {
|
||||
id: 1,
|
||||
mode: LGraphEventMode.NEVER // Already in NEVER mode
|
||||
})
|
||||
|
||||
app.canvas.selected_nodes = { '0': subgraphNode }
|
||||
mockCanvas.selectedItems = new Set([subgraphNode])
|
||||
|
||||
// Toggle to NEVER mode (but subgraphNode is already NEVER)
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
@@ -347,6 +379,32 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
expect(subNode2.mode).toBe(LGraphEventMode.BYPASS)
|
||||
})
|
||||
|
||||
it('reads only the canonical selectedItems set, ignoring the legacy dict', () => {
|
||||
const { getSelectedNodes, areAllSelectedNodesInMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const selected = makeNode(LGraphEventMode.BYPASS, 1)
|
||||
const legacyOnly = makeNode(LGraphEventMode.ALWAYS, 99)
|
||||
|
||||
mockCanvas.selectedItems = new Set([selected])
|
||||
// A different node lives only in the legacy dict; it must be ignored.
|
||||
mockApp.canvas.selected_nodes = { '0': legacyOnly }
|
||||
|
||||
const selectedNodes = getSelectedNodes()
|
||||
expect(selectedNodes).toHaveLength(1)
|
||||
expect(selectedNodes).toContain(selected)
|
||||
expect(selectedNodes).not.toContain(legacyOnly)
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty without throwing when the canvas is unavailable', () => {
|
||||
vi.spyOn(canvasStore, 'canvas', 'get').mockReturnValue(null)
|
||||
const { getSelectedNodes, areAllSelectedNodesInMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
|
||||
expect(getSelectedNodes()).toEqual([])
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic behavior', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { collectFromNodes } from '@/utils/graphTraversalUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling selected LiteGraph items filtering and operations.
|
||||
@@ -63,6 +63,16 @@ export function useSelectedLiteGraphItems() {
|
||||
return getSelectableItems().size > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level selected nodes from the canonical selection set.
|
||||
* Shallow — does NOT expand subgraph children, unlike {@link getSelectedNodes}.
|
||||
* Mode toggles use this so they apply to the selected subgraph node, not its
|
||||
* descendants. Returns `[]` when the canvas is not yet available, preserving
|
||||
* the prior null-tolerance for callers wired to early-firing commands.
|
||||
*/
|
||||
const getSelectedNodesShallow = (): LGraphNode[] =>
|
||||
Array.from(canvasStore.canvas?.selectedItems ?? []).filter(isLGraphNode)
|
||||
|
||||
/**
|
||||
* Get only the selected nodes (LGraphNode instances) from the canvas.
|
||||
* This filters out other types of selected items like groups or reroutes.
|
||||
@@ -70,32 +80,16 @@ export function useSelectedLiteGraphItems() {
|
||||
* @returns Array of selected LGraphNode instances and their descendants.
|
||||
*/
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
if (!selectedNodes) return []
|
||||
const nodeArray = getSelectedNodesShallow()
|
||||
|
||||
// Convert selected_nodes object to array, preserving order
|
||||
const nodeArray: LGraphNode[] = []
|
||||
for (const i in selectedNodes) {
|
||||
nodeArray.push(selectedNodes[i])
|
||||
}
|
||||
|
||||
// Check if any selected nodes are subgraphs
|
||||
const hasSubgraphs = nodeArray.some(
|
||||
(node) => node.isSubgraphNode?.() && node.subgraph
|
||||
)
|
||||
if (!hasSubgraphs) return nodeArray
|
||||
|
||||
// If no subgraphs, just return the array directly to preserve order
|
||||
if (!hasSubgraphs) {
|
||||
return nodeArray
|
||||
}
|
||||
|
||||
// Use collectFromNodes to get all nodes including those in subgraphs
|
||||
return collectFromNodes(nodeArray)
|
||||
}
|
||||
|
||||
const getSelectedNodesShallow = (): LGraphNode[] =>
|
||||
Object.values(app.canvas.selected_nodes ?? {})
|
||||
|
||||
/**
|
||||
* True iff every selected node is in `mode`. Mirrors the predicate used by
|
||||
* {@link toggleSelectedNodesMode} so labels match the toggle's effect.
|
||||
|
||||
@@ -5,17 +5,16 @@ import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selected_nodes: null as Record<string, LGraphNode> | null
|
||||
}
|
||||
// canvasStore transitively imports the app singleton; stub it so the real
|
||||
// ComfyApp module never loads during these unit tests.
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: null } }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [],
|
||||
@@ -44,18 +43,19 @@ const i18n = createI18n({
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const setSelectedNodes = (nodes: LGraphNode[]) => {
|
||||
const dict: Record<string, LGraphNode> = {}
|
||||
nodes.forEach((n, i) => {
|
||||
dict[String(i)] = n
|
||||
})
|
||||
mockApp.canvas.selected_nodes = dict
|
||||
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode => {
|
||||
const node = new LGraphNode('Test')
|
||||
node.id = id
|
||||
node.mode = mode
|
||||
return node
|
||||
}
|
||||
|
||||
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode =>
|
||||
({ id, mode }) as LGraphNode
|
||||
const getBypassLabel = (selected: LGraphNode[]): string => {
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.spyOn(canvasStore, 'canvas', 'get').mockReturnValue({
|
||||
selectedItems: new Set<Positionable>(selected)
|
||||
} as ReturnType<typeof canvasStore.getCanvas>)
|
||||
|
||||
const getBypassLabel = (): string => {
|
||||
let label = ''
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
@@ -71,27 +71,29 @@ const getBypassLabel = (): string => {
|
||||
describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockApp.canvas.selected_nodes = null
|
||||
})
|
||||
|
||||
it('labels as "Bypass" when no node is bypassed', () => {
|
||||
setSelectedNodes([nodeWithMode(LGraphEventMode.ALWAYS, 1)])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Bypass')
|
||||
expect(getBypassLabel([nodeWithMode(LGraphEventMode.ALWAYS, 1)])).toBe(
|
||||
'contextMenu.Bypass'
|
||||
)
|
||||
})
|
||||
|
||||
it('labels as "Remove Bypass" when every selected node is bypassed', () => {
|
||||
setSelectedNodes([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 2)
|
||||
])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Remove Bypass')
|
||||
expect(
|
||||
getBypassLabel([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 2)
|
||||
])
|
||||
).toBe('contextMenu.Remove Bypass')
|
||||
})
|
||||
|
||||
it('labels as "Bypass" on mixed selection so it matches the toggle action', () => {
|
||||
setSelectedNodes([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.ALWAYS, 2)
|
||||
])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Bypass')
|
||||
expect(
|
||||
getBypassLabel([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.ALWAYS, 2)
|
||||
])
|
||||
).toBe('contextMenu.Bypass')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -37,7 +38,8 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
if (!draggedNode.value) return false
|
||||
const nodeDef = draggedNode.value
|
||||
if (!nodeDef) return false
|
||||
const canvas = useCanvasStore().canvas
|
||||
if (!canvas) return false
|
||||
if (!isOverCanvas(clientX, clientY)) return false
|
||||
@@ -46,7 +48,9 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
|
||||
const node = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
if (node) canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/as
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
@@ -146,9 +147,11 @@ export function useJobMenu(
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
)
|
||||
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
|
||||
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
|
||||
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -37,7 +38,9 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
// Add an offset on y to make sure after adding the node, the cursor
|
||||
// is on the node (top left corner)
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
} else if (node.data instanceof ComfyModelDef) {
|
||||
const model = node.data
|
||||
const pos = basePos
|
||||
@@ -58,11 +61,8 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
if (!targetGraphNode) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
targetGraphNode = litegraphService.addNodeOnGraph(
|
||||
provider.nodeDef,
|
||||
{
|
||||
pos
|
||||
}
|
||||
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
|
||||
)
|
||||
targetProvider = provider
|
||||
}
|
||||
|
||||
@@ -2291,7 +2291,8 @@
|
||||
"auth/invalid-credential": "Invalid login credentials. Please check your email and password.",
|
||||
"auth/network-request-failed": "Network error. Please check your connection and try again.",
|
||||
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
|
||||
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
|
||||
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again.",
|
||||
"generic": "Something went wrong while signing you in. Please try again."
|
||||
},
|
||||
"deleteAccount": {
|
||||
"contactSupport": "To delete your account, please contact {email}"
|
||||
@@ -2962,6 +2963,29 @@
|
||||
"cloudStart_learnAboutButton": "Learn about Cloud",
|
||||
"cloudStart_wantToRun": "Want to run ComfyUI locally instead?",
|
||||
"cloudStart_download": "Download ComfyUI",
|
||||
"cloudHero": {
|
||||
"previousSlide": "Previous slide",
|
||||
"nextSlide": "Next slide",
|
||||
"slidePagerLabel": "Go to slide {index}",
|
||||
"slides": {
|
||||
"cloud": {
|
||||
"title": "Cloud",
|
||||
"description": "Best for most users who want to work from anywhere with models verified for commercial license."
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Workflows",
|
||||
"description": "From idea to output in minutes. Generate multiple variations side by side."
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"description": "Onboard your team today. Share workflows and assets across your organization."
|
||||
},
|
||||
"models": {
|
||||
"title": "Models",
|
||||
"description": "Curated, commercially licensed models ready to run with zero setup."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudWaitlist_questionsText": "Questions? Contact us",
|
||||
"cloudWaitlist_contactLink": "here",
|
||||
"cloudSorryContactSupport_title": "Sorry, contact support",
|
||||
|
||||
@@ -6,6 +6,7 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
@@ -61,7 +62,8 @@ function widgetValueVariantsForAsset(asset: AssetItem): string[] {
|
||||
variants.push(`${name} [input]`)
|
||||
}
|
||||
}
|
||||
if (asset.asset_hash) variants.push(asset.asset_hash)
|
||||
const hash = asset.hash ?? asset.asset_hash
|
||||
if (hash) variants.push(hash)
|
||||
return variants
|
||||
}
|
||||
|
||||
@@ -279,9 +281,11 @@ export function useMediaAssetActions() {
|
||||
return
|
||||
}
|
||||
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
)
|
||||
|
||||
if (!node) {
|
||||
toast.add({
|
||||
@@ -296,12 +300,11 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const assetType = getAssetType(targetAsset, 'input')
|
||||
|
||||
// In Cloud mode, use asset_hash (the actual stored filename)
|
||||
// In OSS mode, use the original name
|
||||
const filename =
|
||||
isCloud && targetAsset.asset_hash
|
||||
? targetAsset.asset_hash
|
||||
: targetAsset.name
|
||||
// In Cloud mode, use the content hash (the actual stored filename),
|
||||
// preferring hash and falling back to the deprecated asset_hash alias.
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = targetAsset.hash ?? targetAsset.asset_hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
|
||||
|
||||
// Create annotated path for the asset
|
||||
const annotated = createAnnotatedPath(
|
||||
@@ -425,12 +428,14 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
|
||||
const center = litegraphService.getCanvasCenter()
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: [
|
||||
center[0] + nodeIndex * NODE_OFFSET,
|
||||
center[1] + nodeIndex * NODE_OFFSET
|
||||
]
|
||||
})
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: [
|
||||
center[0] + nodeIndex * NODE_OFFSET,
|
||||
center[1] + nodeIndex * NODE_OFFSET
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
if (!node) {
|
||||
failed++
|
||||
@@ -440,10 +445,11 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const assetType = getAssetType(asset, 'input')
|
||||
|
||||
// In Cloud mode, use asset_hash (the actual stored filename)
|
||||
// In OSS mode, use the original name
|
||||
const filename =
|
||||
isCloud && asset.asset_hash ? asset.asset_hash : asset.name
|
||||
// In Cloud mode, use the content hash (the actual stored filename),
|
||||
// preferring hash and falling back to the deprecated asset_hash alias.
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = asset.hash ?? asset.asset_hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : asset.name
|
||||
|
||||
const annotated = createAnnotatedPath(
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod'
|
||||
const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
hash: z.string().nullish(),
|
||||
asset_hash: z.string().nullish(),
|
||||
size: z.number().optional(), // TBD: Will be provided by history API in the future
|
||||
mime_type: z.string().nullish(),
|
||||
|
||||
@@ -206,5 +206,5 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
* values or media URLs that must round-trip through the view endpoint.
|
||||
*/
|
||||
export function getAssetUrlFilename(asset: AssetItem): string {
|
||||
return asset.asset_hash || asset.name
|
||||
return asset.hash ?? asset.asset_hash ?? asset.name
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAssetsStore } from '@/stores/assetsStore'
|
||||
interface AssetRecord {
|
||||
id: string
|
||||
name: string
|
||||
hash?: string
|
||||
asset_hash?: string
|
||||
preview_url?: string
|
||||
preview_id?: string | null
|
||||
@@ -42,7 +43,7 @@ export async function findOutputAsset(
|
||||
name: string
|
||||
): Promise<AssetRecord | undefined> {
|
||||
const byHash = await fetchAssets({ asset_hash: name })
|
||||
const hashMatch = byHash.find((a) => a.asset_hash === name)
|
||||
const hashMatch = byHash.find((a) => (a.hash ?? a.asset_hash) === name)
|
||||
if (hashMatch) return hashMatch
|
||||
|
||||
const byName = await fetchAssets({ name_contains: name })
|
||||
|
||||
@@ -124,5 +124,11 @@ const handleSubmit = async () => {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: #2d2e32 !important;
|
||||
color: var(--color-primary-comfy-canvas) !important;
|
||||
caret-color: var(--color-primary-comfy-canvas);
|
||||
}
|
||||
|
||||
:deep(.p-inputtext::placeholder) {
|
||||
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,97 +1,83 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-center p-8">
|
||||
<div class="max-w-screen p-2 lg:w-96">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex flex-col gap-4">
|
||||
<h1 class="my-0 text-xl/normal font-medium">
|
||||
<div
|
||||
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
|
||||
>
|
||||
<div class="flex w-full max-w-md flex-col items-start">
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<h1
|
||||
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
{{ t('auth.login.title') }}
|
||||
</h1>
|
||||
<i18n-t
|
||||
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
|
||||
keypath="auth.login.signUpFreeTierPromo"
|
||||
tag="p"
|
||||
class="my-0 text-base text-muted"
|
||||
:plural="freeTierCredits ?? undefined"
|
||||
<p
|
||||
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
|
||||
>
|
||||
<template #signUp>
|
||||
<span
|
||||
class="cursor-pointer text-blue-500"
|
||||
@click="navigateToSignup"
|
||||
>{{ t('auth.login.signUp') }}</span
|
||||
>
|
||||
</template>
|
||||
<template #credits>{{ freeTierCredits }}</template>
|
||||
</i18n-t>
|
||||
<p v-else class="my-0 text-base text-muted">
|
||||
{{ t('auth.login.newUser') }}
|
||||
<span
|
||||
class="cursor-pointer text-blue-500"
|
||||
class="cursor-pointer text-azure-600"
|
||||
@click="navigateToSignup"
|
||||
>{{ t('auth.login.signUp') }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
|
||||
<template v-if="!showEmailForm">
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
|
||||
<template v-if="!showEmailForm">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
class="h-10 w-full"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
<i class="pi pi-google text-base" />
|
||||
{{ t('auth.login.loginWithGoogle') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10 bg-charcoal-500"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
<i class="pi pi-github text-base" />
|
||||
{{ t('auth.login.loginWithGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
variant="link"
|
||||
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
|
||||
@click="switchToEmailForm"
|
||||
>
|
||||
{{ t('auth.login.useEmailInstead') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
|
||||
<template v-else>
|
||||
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
variant="secondary"
|
||||
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-primary-comfy-canvas/10"
|
||||
@click="switchToSocialLogin"
|
||||
>
|
||||
{{ t('auth.login.backToSocialLogin') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
<p class="mt-5 text-sm text-gray-600">
|
||||
<p
|
||||
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
@@ -99,7 +85,7 @@
|
||||
<a
|
||||
href="https://www.comfy.org/privacy-policy"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
@@ -117,7 +103,6 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
|
||||
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
|
||||
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
|
||||
@@ -129,7 +114,6 @@ const authActions = useAuthActions()
|
||||
const isSecureContext = globalThis.isSecureContext
|
||||
const authError = ref('')
|
||||
const showEmailForm = ref(false)
|
||||
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
|
||||
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
|
||||
const { onAuthSuccess } = usePostAuthRedirect({
|
||||
authError,
|
||||
|
||||
@@ -1,92 +1,81 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-center p-8">
|
||||
<div class="max-w-screen p-2 lg:w-96">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex flex-col gap-4">
|
||||
<h1 class="my-0 text-xl/normal font-medium">
|
||||
<div
|
||||
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
|
||||
>
|
||||
<div class="flex w-full max-w-md flex-col items-start">
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<h1
|
||||
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
{{ t('auth.signup.title') }}
|
||||
</h1>
|
||||
<p class="my-0 text-base">
|
||||
<span class="text-muted">{{
|
||||
<p
|
||||
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
|
||||
>
|
||||
<span class="text-primary-comfy-canvas/70">{{
|
||||
t('auth.signup.alreadyHaveAccount')
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-1 cursor-pointer text-blue-500"
|
||||
class="ml-1 cursor-pointer text-azure-600"
|
||||
@click="navigateToLogin"
|
||||
>{{ t('auth.signup.signIn') }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
|
||||
<template v-if="!showEmailForm">
|
||||
<p
|
||||
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
|
||||
class="mb-4 text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
freeTierCredits
|
||||
? t('auth.login.freeTierDescription', {
|
||||
credits: freeTierCredits
|
||||
})
|
||||
: t('auth.login.freeTierDescriptionGeneric')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="!googleSsoBlockedReason" class="relative">
|
||||
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.signup.signUpWithGoogle') }}
|
||||
</Button>
|
||||
<span
|
||||
v-if="isFreeTierEnabled"
|
||||
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-gray-900"
|
||||
>
|
||||
{{ t('auth.login.freeTierBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
|
||||
<template v-if="!showEmailForm">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google text-base" />
|
||||
{{ t('auth.signup.signUpWithGoogle') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10 bg-charcoal-500"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
<i class="pi pi-github text-base" />
|
||||
{{ t('auth.signup.signUpWithGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
variant="link"
|
||||
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
|
||||
@click="switchToEmailForm"
|
||||
>
|
||||
{{ t('auth.login.useEmailInstead') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Message v-if="isFreeTierEnabled" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
|
||||
</Message>
|
||||
<template v-else>
|
||||
<Message v-if="isFreeTierEnabled" severity="warn" class="w-full">
|
||||
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
|
||||
</Message>
|
||||
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
|
||||
<Message v-if="userIsInChina" severity="warn" class="w-full">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm
|
||||
v-else
|
||||
:auth-error="authError"
|
||||
@submit="signUpWithEmail"
|
||||
/>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
variant="secondary"
|
||||
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-sand-300/10"
|
||||
@click="switchToSocialLogin"
|
||||
>
|
||||
{{
|
||||
@@ -95,16 +84,17 @@
|
||||
: t('auth.login.backToSocialLogin')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
<p class="mt-5 text-sm text-gray-600">
|
||||
<p
|
||||
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
@@ -112,16 +102,18 @@
|
||||
<a
|
||||
href="https://www.comfy.org/privacy-policy"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
<p
|
||||
class="mx-auto mt-2 mb-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('cloudWaitlist_questionsText') }}
|
||||
<a
|
||||
href="https://support.comfy.org"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -159,7 +151,6 @@ const userIsInChina = ref(false)
|
||||
const telemetry = useTelemetry()
|
||||
const {
|
||||
showEmailForm,
|
||||
freeTierCredits,
|
||||
isFreeTierEnabled,
|
||||
switchToEmailForm,
|
||||
switchToSocialLogin
|
||||
@@ -197,7 +188,6 @@ const signUpWithEmail = async (values: SignUpData) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Track signup screen opened
|
||||
if (isCloud) {
|
||||
telemetry?.trackSignupOpened()
|
||||
}
|
||||
@@ -210,12 +200,22 @@ onMounted(async () => {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: #2d2e32 !important;
|
||||
color: var(--color-primary-comfy-canvas) !important;
|
||||
caret-color: var(--color-primary-comfy-canvas);
|
||||
}
|
||||
|
||||
:deep(.p-inputtext::placeholder) {
|
||||
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
|
||||
}
|
||||
|
||||
:deep(.p-password input) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:deep(.p-password-toggle-mask-icon) {
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.p-checkbox-checked .p-checkbox-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
|
||||
BIN
src/platform/cloud/onboarding/assets/hero/bottom-left.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
src/platform/cloud/onboarding/assets/hero/bottom-right.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
src/platform/cloud/onboarding/assets/hero/center-image.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/platform/cloud/onboarding/assets/hero/top-left.jpg
Normal file
|
After Width: | Height: | Size: 121 KiB |
106
src/platform/cloud/onboarding/components/CloudHeroCarousel.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex size-full flex-col items-center overflow-hidden rounded-lg bg-primary-comfy-canvas/4 pt-10 pb-4"
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-1 flex-col items-center justify-center gap-6 md:gap-8 lg:gap-10"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-5/4 w-full max-w-xs md:max-w-sm lg:max-w-md xl:max-w-lg"
|
||||
>
|
||||
<div class="absolute inset-0">
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 aspect-3/2 w-3/4 -translate-1/2"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-3xl border border-white/20 bg-white/10 p-3.5 shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="size-full overflow-hidden rounded-xl bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: `url(${centerImage})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute -top-1/20 -right-1/50 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<i class="icon-[comfy--gemini-mono] size-3.5" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute -bottom-2/25 left-1/2 flex aspect-square w-10 -translate-x-1/2 items-center justify-center rounded-xl border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<i class="icon-[comfy--grok] size-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-3/20 left-1/20 aspect-5/3 w-7/20">
|
||||
<div
|
||||
class="absolute inset-0 overflow-hidden rounded-2xl border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
|
||||
:style="{ backgroundImage: `url(${topLeft})` }"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute -top-1/10 right-1/10 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<i class="icon-[comfy--bytedance-mono] size-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-3/20 left-1/10 aspect-4/3 w-1/4 overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat shadow-2xl"
|
||||
:style="{ backgroundImage: `url(${bottomLeft})` }"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute right-2/25 bottom-1/10 aspect-4/3 w-3/10 overflow-hidden rounded-lg border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
|
||||
:style="{ backgroundImage: `url(${bottomRight})` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex w-full max-w-md flex-col items-center gap-1 text-center"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-inter text-base font-semibold text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('cloudHero.slides.cloud.title') }}
|
||||
</p>
|
||||
<p class="m-0 font-inter text-sm text-primary-comfy-canvas/70">
|
||||
{{ t('cloudHero.slides.cloud.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="flex w-full items-center justify-center gap-3 pt-6"
|
||||
>
|
||||
<p class="m-0 hidden text-sm text-primary-comfy-canvas/90 md:block">
|
||||
{{ t('cloudStart_wantToRun') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://www.comfy.org/download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-lg border border-primary-comfy-canvas/20 bg-charcoal-500 p-2 text-xs font-medium text-primary-comfy-canvas no-underline hover:bg-charcoal-500/80"
|
||||
>
|
||||
<i class="pi pi-download text-xs text-primary-comfy-canvas/90" />
|
||||
{{ t('cloudStart_download') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import bottomLeft from '@/platform/cloud/onboarding/assets/hero/bottom-left.jpg'
|
||||
import bottomRight from '@/platform/cloud/onboarding/assets/hero/bottom-right.jpg'
|
||||
import centerImage from '@/platform/cloud/onboarding/assets/hero/center-image.jpg'
|
||||
import topLeft from '@/platform/cloud/onboarding/assets/hero/top-left.jpg'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
|
||||
<img
|
||||
src="/assets/images/comfy-cloud-logo.svg"
|
||||
:alt="$t('subscription.comfyCloudLogo')"
|
||||
class="h-3/4 max-h-10 w-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,7 +67,8 @@
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium text-white"
|
||||
variant="secondary"
|
||||
class="relative mt-4 h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
:disabled="!$form.valid"
|
||||
>
|
||||
{{ t('auth.login.loginButton') }}
|
||||
@@ -117,12 +118,22 @@ const onSubmit = (event: FormSubmitEvent) => {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: #2d2e32 !important;
|
||||
color: var(--color-primary-comfy-canvas) !important;
|
||||
caret-color: var(--color-primary-comfy-canvas);
|
||||
}
|
||||
|
||||
:deep(.p-inputtext::placeholder) {
|
||||
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
|
||||
}
|
||||
|
||||
:deep(.p-password input) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:deep(.p-password-toggle-mask-icon) {
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.p-checkbox-checked .p-checkbox-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
|
||||
@@ -1,81 +1,27 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<BaseViewTemplate dark class="flex-1">
|
||||
<template #header>
|
||||
<CloudLogo />
|
||||
</template>
|
||||
<slot />
|
||||
<template #footer>
|
||||
<CloudTemplateFooter />
|
||||
</template>
|
||||
</BaseViewTemplate>
|
||||
<div class="relative hidden flex-1 overflow-hidden bg-black lg:block">
|
||||
<!-- Video Background -->
|
||||
<video
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
:poster="videoPoster"
|
||||
>
|
||||
<source :src="videoSrc" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div class="absolute inset-0 size-full bg-black/30"></div>
|
||||
|
||||
<!-- Optional Overlay for better visual -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center text-center text-white"
|
||||
>
|
||||
<div>
|
||||
<h1 class="font-abcrom hero-title font-black uppercase italic">
|
||||
{{ t('cloudStart_title') }}
|
||||
</h1>
|
||||
<p class="m-2 text-center text-xl text-white">
|
||||
{{ t('cloudStart_desc') }}
|
||||
</p>
|
||||
<p class="m-0 text-center text-xl text-white">
|
||||
{{ t('cloudStart_explain') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-svh w-screen bg-primary-comfy-ink font-sans text-primary-comfy-canvas"
|
||||
>
|
||||
<div class="relative flex flex-1 flex-col">
|
||||
<div class="flex h-16 shrink-0 items-center px-6">
|
||||
<i
|
||||
class="icon-[comfy--comfy-logo] h-5 w-22 text-brand-yellow md:h-6 md:w-26"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-0 flex flex-col justify-end px-14 pb-[64px]">
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-md text-white">
|
||||
{{ t('cloudStart_wantToRun') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10 bg-black font-bold text-white"
|
||||
variant="secondary"
|
||||
@click="handleDownloadClick"
|
||||
>
|
||||
{{ t('cloudStart_download') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-center overflow-auto">
|
||||
<slot />
|
||||
</div>
|
||||
<CloudTemplateFooter />
|
||||
</div>
|
||||
<div class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block">
|
||||
<CloudHeroCarousel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import videoPoster from '@/platform/cloud/onboarding/assets/videos/thumbnail.png'
|
||||
import videoSrc from '@/platform/cloud/onboarding/assets/videos/video.mp4'
|
||||
import CloudLogo from '@/platform/cloud/onboarding/components/CloudLogo.vue'
|
||||
import CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
|
||||
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const handleDownloadClick = () => {
|
||||
window.open('https://www.comfy.org/download', '_blank')
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@import '../assets/css/fonts.css';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<footer class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start gap-2.5">
|
||||
<footer
|
||||
class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start justify-center gap-2.5"
|
||||
>
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
|
||||
@@ -85,7 +85,7 @@ export function getAssetDetectionNames(
|
||||
): string[] {
|
||||
const names = new Set<string>()
|
||||
// Treat names and hashes as opaque match keys because Cloud may use either in widget values.
|
||||
addPathDetectionNames(names, asset.asset_hash, options)
|
||||
addPathDetectionNames(names, asset.hash ?? asset.asset_hash, options)
|
||||
addPathDetectionNames(names, asset.name, options)
|
||||
|
||||
const subfolder = asset.user_metadata?.subfolder
|
||||
|
||||
@@ -501,7 +501,8 @@ function isAssetInstalled(
|
||||
): boolean {
|
||||
if (candidate.hash && candidate.hashType) {
|
||||
const candidateHash = `${candidate.hashType}:${candidate.hash}`
|
||||
if (assets.some((a) => a.asset_hash === candidateHash)) return true
|
||||
if (assets.some((a) => (a.hash ?? a.asset_hash) === candidateHash))
|
||||
return true
|
||||
}
|
||||
|
||||
const normalizedName = normalizePath(candidate.name)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
@@ -198,6 +199,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
)
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackNodeAdded?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplateFilterChanged?.(metadata))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
import { installNodeAddedTelemetry } from './installNodeAddedTelemetry'
|
||||
import { withNodeAddSource } from './nodeAddSource'
|
||||
|
||||
const trackNodeAdded = vi.fn()
|
||||
|
||||
vi.mock('..', () => ({
|
||||
useTelemetry: () => ({ trackNodeAdded })
|
||||
}))
|
||||
|
||||
function fakeGraph(): LGraph {
|
||||
return { onNodeAdded: undefined } as unknown as LGraph
|
||||
}
|
||||
|
||||
function fakeNode(type: string): LGraphNode {
|
||||
return { type } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('installNodeAddedTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
trackNodeAdded.mockClear()
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
})
|
||||
|
||||
it('fires trackNodeAdded with the current source on add', () => {
|
||||
const graph = fakeGraph()
|
||||
installNodeAddedTelemetry(graph)
|
||||
|
||||
withNodeAddSource('sidebar_drag', () => {
|
||||
graph.onNodeAdded?.(fakeNode('KSampler'))
|
||||
})
|
||||
|
||||
expect(trackNodeAdded).toHaveBeenCalledExactlyOnceWith({
|
||||
node_type: 'KSampler',
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults source to "unknown" outside withNodeAddSource', () => {
|
||||
const graph = fakeGraph()
|
||||
installNodeAddedTelemetry(graph)
|
||||
|
||||
graph.onNodeAdded?.(fakeNode('CheckpointLoader'))
|
||||
|
||||
expect(trackNodeAdded).toHaveBeenCalledWith({
|
||||
node_type: 'CheckpointLoader',
|
||||
source: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips telemetry during workflow load', () => {
|
||||
const graph = fakeGraph()
|
||||
installNodeAddedTelemetry(graph)
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
|
||||
graph.onNodeAdded?.(fakeNode('VAEDecode'))
|
||||
|
||||
expect(trackNodeAdded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves an existing onNodeAdded subscriber', () => {
|
||||
const graph = fakeGraph()
|
||||
const previous = vi.fn()
|
||||
graph.onNodeAdded = previous
|
||||
installNodeAddedTelemetry(graph)
|
||||
|
||||
const node = fakeNode('LoadImage')
|
||||
graph.onNodeAdded?.(node)
|
||||
|
||||
expect(previous).toHaveBeenCalledExactlyOnceWith(node)
|
||||
expect(trackNodeAdded).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
import { useTelemetry } from '..'
|
||||
import { getCurrentNodeAddSource } from './nodeAddSource'
|
||||
|
||||
/**
|
||||
* Wire `app:node_added` telemetry into a graph. Wraps any existing
|
||||
* `onNodeAdded` callback so we don't displace other subscribers. Bulk
|
||||
* additions during workflow load are skipped — `workflow_imported`
|
||||
* already covers those.
|
||||
*/
|
||||
export function installNodeAddedTelemetry(graph: LGraph): void {
|
||||
const previous = graph.onNodeAdded
|
||||
graph.onNodeAdded = function (node) {
|
||||
previous?.call(this, node)
|
||||
if (ChangeTracker.isLoadingGraph) return
|
||||
useTelemetry()?.trackNodeAdded({
|
||||
node_type: node.type ?? 'unknown',
|
||||
source: getCurrentNodeAddSource()
|
||||
})
|
||||
}
|
||||
}
|
||||
22
src/platform/telemetry/nodeAdded/nodeAddSource.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { NodeAddSource } from '../types'
|
||||
|
||||
let currentSource: NodeAddSource = 'unknown'
|
||||
|
||||
export function getCurrentNodeAddSource(): NodeAddSource {
|
||||
return currentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the node-add source for the duration of `fn`. Synchronous only —
|
||||
* the source is read by the synchronous LGraph.onNodeAdded callback that
|
||||
* fires inside `graph.add()`. Nesting restores the previous value on exit.
|
||||
*/
|
||||
export function withNodeAddSource<T>(source: NodeAddSource, fn: () => T): T {
|
||||
const previous = currentSource
|
||||
currentSource = source
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
currentSource = previous
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeAddedMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
@@ -316,6 +317,13 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
})
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.pushEvent('node_added', {
|
||||
node_type: metadata.node_type,
|
||||
source: metadata.source
|
||||
})
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.pushEvent('template_filter', {
|
||||
search_query: metadata.search_query,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeAddedMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
@@ -400,6 +401,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.NODE_ADDED, metadata)
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -7,20 +7,25 @@ const hoisted = vi.hoisted(() => {
|
||||
const mockInit = vi.fn()
|
||||
const mockIdentify = vi.fn()
|
||||
const mockPeopleSet = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockOnUserResolved = vi.fn()
|
||||
const mockOnUserLogout = vi.fn()
|
||||
|
||||
return {
|
||||
mockCapture,
|
||||
mockInit,
|
||||
mockIdentify,
|
||||
mockPeopleSet,
|
||||
mockReset,
|
||||
mockOnUserResolved,
|
||||
mockOnUserLogout,
|
||||
mockPosthog: {
|
||||
default: {
|
||||
init: mockInit,
|
||||
capture: mockCapture,
|
||||
identify: mockIdentify,
|
||||
people: { set: mockPeopleSet }
|
||||
people: { set: mockPeopleSet },
|
||||
reset: mockReset
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +41,8 @@ vi.mock('vue', async () => {
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: hoisted.mockOnUserResolved
|
||||
onUserResolved: hoisted.mockOnUserResolved,
|
||||
onUserLogout: hoisted.mockOnUserLogout
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -130,7 +136,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies user when onUserResolved fires', async () => {
|
||||
it('identifies user without setting first_auth_at when onUserResolved fires', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -166,6 +172,88 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sets first_auth_at on new-user auth', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
first_auth_at: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not set first_auth_at on returning-user auth', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: false,
|
||||
user_id: 'user-123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{
|
||||
method: 'google',
|
||||
is_new_user: false,
|
||||
user_id: 'user-123'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes queued first_auth_at before queued auth event', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
first_auth_at: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
}
|
||||
)
|
||||
expect(hoisted.mockIdentify.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.mockCapture.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('queues events before initialization and flushes after', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
@@ -236,6 +324,32 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('registers onUserLogout watcher after init', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockOnUserLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls posthog.reset(true) when the watcher fires', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserLogout.mock.calls[0][0]
|
||||
callback()
|
||||
|
||||
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('does not register the watcher before init resolves', () => {
|
||||
createProvider()
|
||||
|
||||
expect(hoisted.mockOnUserLogout).not.toHaveBeenCalled()
|
||||
expect(hoisted.mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('page view', () => {
|
||||
it('captures page view with page_name property', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
@@ -84,6 +85,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private isEnabled = true
|
||||
private posthog: PostHog | null = null
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private pendingFirstAuthAt = new Map<string, string>()
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
@@ -127,12 +129,25 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
|
||||
useCurrentUser().onUserResolved((user) => {
|
||||
const currentUser = useCurrentUser()
|
||||
currentUser.onUserResolved((user) => {
|
||||
if (this.posthog && user.id) {
|
||||
this.posthog.identify(user.id)
|
||||
this.setSubscriptionProperties()
|
||||
}
|
||||
})
|
||||
// Anchored to session state rather than the logout button so it
|
||||
// also covers token revocation, account deletion, and cross-tab
|
||||
// sign-out (browserLocalPersistence). A logout that lands during
|
||||
// the posthog-js dynamic-import window will not be observed here:
|
||||
// events buffered pre-init are intentionally NOT queue-cleared on
|
||||
// logout, which leaves a narrow race where a logout + different
|
||||
// login both inside the import window would flush pre-init events
|
||||
// under the new identity. Accepted as a known edge — re-adding
|
||||
// pre-init logout handling would defeat the simplification.
|
||||
currentUser.onUserLogout(() => {
|
||||
this.posthog?.reset(true)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load PostHog:', error)
|
||||
@@ -151,6 +166,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private flushEventQueue(): void {
|
||||
if (!this.isInitialized || !this.posthog) return
|
||||
|
||||
this.flushPendingFirstAuthAt()
|
||||
|
||||
while (this.eventQueue.length > 0) {
|
||||
const event = this.eventQueue.shift()!
|
||||
try {
|
||||
@@ -161,6 +178,33 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private flushPendingFirstAuthAt(): void {
|
||||
for (const [userId, firstAuthAt] of this.pendingFirstAuthAt) {
|
||||
this.setFirstAuthAt(userId, firstAuthAt)
|
||||
}
|
||||
this.pendingFirstAuthAt.clear()
|
||||
}
|
||||
|
||||
private setFirstAuthAt(
|
||||
userId: string,
|
||||
firstAuthAt = new Date().toISOString()
|
||||
): void {
|
||||
if (!this.isEnabled) return
|
||||
|
||||
if (this.isInitialized && this.posthog) {
|
||||
try {
|
||||
this.posthog.identify(userId, undefined, { first_auth_at: firstAuthAt })
|
||||
} catch (error) {
|
||||
console.error('Failed to set PostHog first auth timestamp:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.pendingFirstAuthAt.has(userId)) {
|
||||
this.pendingFirstAuthAt.set(userId, firstAuthAt)
|
||||
}
|
||||
}
|
||||
|
||||
private trackEvent(
|
||||
eventName: TelemetryEventName,
|
||||
properties?: TelemetryEventProperties
|
||||
@@ -241,6 +285,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
if (metadata.is_new_user && metadata.user_id) {
|
||||
this.setFirstAuthAt(metadata.user_id)
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
}
|
||||
|
||||
@@ -411,6 +458,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.NODE_ADDED, metadata)
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -232,6 +232,23 @@ export interface NodeSearchMetadata {
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Node added metadata. `source` indicates how the user initiated the add.
|
||||
* Bulk additions during workflow load are excluded — workflow_imported
|
||||
* already covers that.
|
||||
*/
|
||||
export type NodeAddSource =
|
||||
| 'sidebar_drag'
|
||||
| 'search_modal'
|
||||
| 'paste'
|
||||
| 'programmatic'
|
||||
| 'unknown'
|
||||
|
||||
export interface NodeAddedMetadata {
|
||||
node_type: string
|
||||
source: NodeAddSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Node search result selection metadata
|
||||
*/
|
||||
@@ -437,6 +454,9 @@ export interface TelemetryProvider {
|
||||
trackNodeSearch?(metadata: NodeSearchMetadata): void
|
||||
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
|
||||
|
||||
// Node-added-to-canvas analytics
|
||||
trackNodeAdded?(metadata: NodeAddedMetadata): void
|
||||
|
||||
// Template filter tracking events
|
||||
trackTemplateFilterChanged?(metadata: TemplateFilterMetadata): void
|
||||
|
||||
@@ -523,6 +543,7 @@ export const TelemetryEvents = {
|
||||
// Node Search Analytics
|
||||
NODE_SEARCH: 'app:node_search',
|
||||
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
|
||||
NODE_ADDED: 'app:node_added',
|
||||
|
||||
// Template Filter Analytics
|
||||
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
|
||||
|
||||
@@ -183,7 +183,7 @@ const createInputMappingWidget = (
|
||||
getMediaTypeFromFilename(asset.name) ===
|
||||
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
|
||||
)
|
||||
.map((asset) => asset.asset_hash)
|
||||
.map((asset) => asset.hash ?? asset.asset_hash)
|
||||
.filter((hash): hash is string => !!hash)
|
||||
)
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
// already carry their own URL-resolvable filename. Expanding them via
|
||||
// resolveOutputAssetItems would synthesize sibling AssetItems without
|
||||
// an asset_hash and reintroduce the FE-227 hash→name fallback bug.
|
||||
if (asset.asset_hash) continue
|
||||
if (asset.hash ?? asset.asset_hash) continue
|
||||
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
@@ -25,6 +25,7 @@ import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { installNodeAddedTelemetry } from '@/platform/telemetry/nodeAdded/installNodeAddedTelemetry'
|
||||
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
|
||||
@@ -888,6 +889,7 @@ export class ComfyApp {
|
||||
this.addAfterConfigureHandler(graph)
|
||||
|
||||
this.rootGraphInternal = graph
|
||||
installNodeAddedTelemetry(graph)
|
||||
this.canvas = new LGraphCanvas(canvasEl, graph)
|
||||
// Make canvas states reactive so we can observe changes on them.
|
||||
this.canvas.state = reactive(this.canvas.state)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/** @knipIgnoreUnusedButUsedByCustomNodes */
|
||||
export function getFromFlacBuffer(buffer: ArrayBuffer): Record<string, string> {
|
||||
const dataView = new DataView(buffer)
|
||||
|
||||
|
||||
@@ -352,8 +352,9 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
const inputAssetsByFilename = computed(() => {
|
||||
const map = new Map<string, AssetItem>()
|
||||
for (const asset of inputAssets.value) {
|
||||
if (asset.asset_hash) {
|
||||
map.set(asset.asset_hash, asset)
|
||||
const hash = asset.hash ?? asset.asset_hash
|
||||
if (hash) {
|
||||
map.set(hash, asset)
|
||||
}
|
||||
}
|
||||
return map
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
|
||||
/**
|
||||
* Serialises an array of nodes using a modified version of the old Litegraph copy (& paste) function
|
||||
@@ -106,7 +107,7 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void {
|
||||
node.pos[1] += graph_mouse[1] - topLeft[1]
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
graph.add(node, true)
|
||||
withNodeAddSource('paste', () => graph.add(node, true))
|
||||
nodes.push(node)
|
||||
}
|
||||
|
||||
|
||||