Compare commits

..

2 Commits

Author SHA1 Message Date
shrimbly
f55a0d42d9 fix: address media asset review feedback 2026-07-11 04:10:45 +12:00
shrimbly
a81e20228c feat: revamp media assets sidebar filtering, search and view options
Ports the media-assets sidebar UX from the prototype:
- Cascading filter menu with hover flyout submenus, flat cross-facet value
  search, and full keyboard nav (Media type + Date facets)
- Applied-filter chips with clear-all
- Filled search field
- List / Grid (small) / Grid (large) view modes + A-Z / Z-A sort
- Card meta line (FORMAT + dimensions/size), overlay actions
- Cmd/Ctrl+A select-all scoped to the asset pane, plus "Unselect N"

Replaces the old filter button with the new flyout menu. No sharing behavior.
2026-07-10 14:20:37 +12:00
90 changed files with 2017 additions and 3753 deletions

View File

@@ -40,6 +40,3 @@ jobs:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build
- name: Validate JSON-LD structured data
run: pnpm --filter @comfyorg/website validate:jsonld

View File

@@ -76,14 +76,10 @@ test.describe('Affiliates landing — desktop interactions', () => {
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
'@type': string
mainEntity?: unknown[]
}[]
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({

View File

@@ -1,158 +0,0 @@
import { expect } from '@playwright/test'
import { learningTutorials } from '../src/data/learningTutorials'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
test.describe('Learning page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learning')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning — Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
await expect(heading).toContainText('ComfyUI')
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
})
test('featured workflow section shows title and author', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.featured.title', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByText(t('learning.featured.author', 'en'))
).toBeVisible()
})
test('renders every tutorial from the data source', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'en'),
level: 2
})
).toBeVisible()
for (const tutorial of learningTutorials) {
await expect(
page.getByRole('button', {
name: tutorialButtonName(tutorial.title.en, 'en')
})
).toBeVisible()
}
})
test('tutorials with a workflow link expose an external Try Workflow link', async ({
page
}) => {
const linkedTutorials = learningTutorials.filter(
(tutorial) => tutorial.href
)
const workflowLinks = page.getByRole('link', {
name: t('cta.tryWorkflow', 'en')
})
const hrefs = await workflowLinks.evaluateAll((links) =>
links.map((link) => link.getAttribute('href'))
)
for (const tutorial of linkedTutorials) {
expect(hrefs).toContain(tutorial.href)
}
})
test('call to action links to contact sales', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.cta.heading', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
).toHaveAttribute('href', '/contact')
})
})
test.describe('Learning tutorial dialog', () => {
test('opens a tutorial video and dismisses via the close button', async ({
page
}) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
// TutorialsSection is hydrated via `client:visible`; retry the click until
// Vue responds by opening the dialog.
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await expect(
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
).toBeVisible()
await dialog
.getByRole('button', { name: t('gallery.detail.close', 'en') })
.click()
await expect(dialog).toBeHidden()
})
test('dismisses the dialog with the Escape key', async ({ page }) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
})
test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 — Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'zh-CN'),
level: 2
})
).toBeVisible()
const [firstTutorial] = learningTutorials
await expect(
page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
})
).toBeVisible()
})
})

View File

@@ -17,8 +17,7 @@
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts",
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
"generate:models": "tsx ./scripts/generate-models.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",

View File

@@ -1,129 +0,0 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { collectGraphIds } from '../src/utils/jsonLd'
const DIST_DIR = join(process.cwd(), 'dist')
const JSON_LD_BLOCK =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
interface Violation {
file: string
message: string
}
function htmlFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true })
.map(String)
.filter((entry) => entry.endsWith('.html'))
.map((entry) => join(dir, entry))
}
function typesOf(node: Record<string, unknown>): string[] {
const type = node['@type']
if (typeof type === 'string') return [type]
if (Array.isArray(type)) {
return type.filter((t): t is string => typeof t === 'string')
}
return []
}
function hasValidPrice(node: Record<string, unknown>): boolean {
const price = node.price
const priceStr = price == null ? '' : String(price).trim()
return priceStr !== '' && !Number.isNaN(Number(priceStr))
}
function checkHonesty(
value: unknown,
file: string,
violations: Violation[]
): void {
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (!node || typeof node !== 'object') return
const record = node as Record<string, unknown>
const types = typesOf(record)
if (types.includes('Review') || types.includes('AggregateRating')) {
violations.push({
file,
message: `dishonest node type ${types.join('/')}`
})
}
if ('aggregateRating' in record || 'review' in record) {
violations.push({
file,
message: 'node carries a review/aggregateRating'
})
}
if (
types.includes('Offer') &&
(!hasValidPrice(record) || !record.priceCurrency)
) {
violations.push({
file,
message: 'Offer missing priceCurrency or a concrete price'
})
}
Object.values(record).forEach(walk)
}
walk(value)
}
function validateFile(file: string): Violation[] {
const html = readFileSync(file, 'utf8')
const violations: Violation[] = []
const definedIds = new Set<string>()
const referencedIds: string[] = []
for (const match of html.matchAll(JSON_LD_BLOCK)) {
let parsed: unknown
try {
parsed = JSON.parse(match[1])
} catch (error) {
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
continue
}
checkHonesty(parsed, file, violations)
const { defined, references } = collectGraphIds(parsed)
defined.forEach((id) => definedIds.add(id))
referencedIds.push(...references)
}
for (const id of referencedIds) {
if (!definedIds.has(id)) {
violations.push({ file, message: `unresolved @id reference: ${id}` })
}
}
return violations
}
function main(): void {
const files = htmlFiles(DIST_DIR)
if (files.length === 0) {
console.error(
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
)
process.exit(1)
}
const violations = files.flatMap(validateFile)
if (violations.length > 0) {
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
for (const { file, message } of violations) {
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
}
process.exit(1)
}
process.stdout.write(
`JSON-LD validation passed across ${files.length} page(s).\n`
)
}
main()

View File

@@ -1,41 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HeroBackdrop01 from './HeroBackdrop01.vue'
const sampleImage =
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
const meta: Meta<typeof HeroBackdrop01> = {
title: 'Website/Blocks/HeroBackdrop01',
component: HeroBackdrop01,
tags: ['autodocs'],
args: {
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
title: 'Build anything\nwith ComfyUI',
subtitle:
'A powerful, modular visual interface for building and running AI workflows.'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithBadge: Story = {
args: {
badgeText: 'New'
}
}
export const WithFootnote: Story = {
args: {
footnote: 'Available on Windows, macOS, and Linux.'
}
}
export const NoBackdrop: Story = {
args: {
backdrop: undefined
}
}

View File

@@ -1,193 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
type Backdrop =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
backdrop,
mobileBackdrop,
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
subtitle,
footnote,
class: className
} = defineProps<{
backdrop?: Backdrop
mobileBackdrop?: Backdrop
badgeText?: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
subtitle?: string
footnote?: string
class?: HTMLAttributes['class']
}>()
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
// Removing the reactive `autoplay` attribute only suppresses the *initial*
// play; it can't pause a video the browser has already started. That is
// exactly the SSR case: the server renders `autoplay` (it can't read the
// client's motion preference), the browser begins playback on parse, and the
// post-hydration attribute removal is too late. Pause on mount so
// reduced-motion users get the poster frame instead of a looping video.
const pauseIfReduced = (el: unknown) => {
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
}
// On mobile the backdrop is an in-flow rounded card above the content; on
// desktop it is the full-bleed background behind it. A single element serves
// both roles via responsive classes — mobileBackdrop only swaps the source.
const sharedBackdropClass =
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
// When both breakpoints use images, serve them from a single responsive <img>
// so the browser fetches only the source matching the viewport. Two
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
// does not stop the fetch), doubling the high-priority load on an
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
// way and fall back to breakpoint-toggled layers below.
const responsiveImage = computed(() => {
if (backdrop?.type !== 'image') return null
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
const base = mobileBackdrop ?? backdrop
return {
src: base.src,
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
// Larger-viewport source; omitted when one image serves both breakpoints.
desktopSrc: mobileBackdrop ? backdrop.src : undefined
}
})
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
const backdropLayers = computed(() => {
if (!backdrop) return []
if (mobileBackdrop) {
return [
{
backdrop: mobileBackdrop,
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
},
{
backdrop,
class: 'absolute inset-0 hidden size-full object-cover lg:block'
}
]
}
return [{ backdrop, class: sharedBackdropClass }]
})
const scrimShape = 'farthest-side at 50% 50%'
const scrimStyle = {
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
}
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
>
<div class="relative overflow-hidden rounded-3xl">
<slot name="backdrop">
<picture v-if="responsiveImage" class="contents">
<source
v-if="responsiveImage.desktopSrc"
:srcset="responsiveImage.desktopSrc"
media="(min-width: 1024px)"
/>
<img
:src="responsiveImage.src"
:alt="responsiveImage.alt"
fetchpriority="high"
decoding="async"
:class="sharedBackdropClass"
/>
</picture>
<template v-else>
<template v-for="(layer, i) in backdropLayers" :key="i">
<video
v-if="layer.backdrop.type === 'video'"
:ref="pauseIfReduced"
:src="layer.backdrop.src"
:poster="layer.backdrop.poster"
:aria-label="layer.backdrop.alt"
:aria-hidden="layer.backdrop.alt ? undefined : true"
:autoplay="!reduceMotion"
loop
muted
playsinline
preload="metadata"
:class="layer.class"
/>
<img
v-else
:src="layer.backdrop.src"
:alt="layer.backdrop.alt ?? ''"
fetchpriority="high"
decoding="async"
:class="layer.class"
/>
</template>
</template>
</slot>
<div
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
>
<div class="relative w-full max-w-xl">
<div
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
/>
<div class="relative">
<ProductHeroBadge
v-if="badgeText"
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
>
{{ subtitle }}
</p>
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
{{ footnote }}
</p>
<slot />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,12 +0,0 @@
---
import type { JsonLdGraph } from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
interface Props {
graph: JsonLdGraph
}
const { graph } = Astro.props
---
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />

View File

@@ -1,53 +0,0 @@
import { t } from '../i18n/translations'
import type { Locale, TranslationKey } from '../i18n/translations'
import { externalLinks } from './routes'
interface PricingTier {
slug: string
labelKey: TranslationKey
priceKey: TranslationKey
}
const tiers: PricingTier[] = [
{
slug: 'standard',
labelKey: 'pricing.plan.standard.label',
priceKey: 'pricing.plan.standard.price'
},
{
slug: 'creator',
labelKey: 'pricing.plan.creator.label',
priceKey: 'pricing.plan.creator.price'
},
{
slug: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price'
}
]
export interface PricingOffer {
name: string
price: string
url: string
}
export function pricingOffers(locale: Locale): PricingOffer[] {
return tiers.flatMap((tier) => {
const display = t(tier.priceKey, locale).trim()
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
if (!match) {
console.warn(
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
)
return []
}
return [
{
name: t(tier.labelKey, locale),
price: match[1],
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
}
]
})
}

View File

@@ -82,19 +82,14 @@ export const externalLinks = {
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
g2ComfyUi: 'https://www.g2.com/products/comfyui',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'

View File

@@ -2190,13 +2190,6 @@ const translations = {
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
@@ -4068,6 +4061,7 @@ const translations = {
en: 'This page is being redesigned. Check back soon.',
'zh-CN': '此页面正在重新设计中,请稍后再来。'
},
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
'customers.story.whatsNext': {
@@ -4163,6 +4157,10 @@ const translations = {
en: "Run the world's leading AI models in ComfyUI",
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
},
'models.breadcrumb.home': {
en: 'Home',
'zh-CN': '首页'
},
'models.breadcrumb.models': {
en: 'Supported Models',
'zh-CN': '支持的模型'

View File

@@ -14,10 +14,8 @@ import {
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { buildPageGraph, pageContext } from '../utils/jsonLd'
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
interface Props {
title: string
@@ -25,10 +23,6 @@ interface Props {
keywords?: string[]
ogImage?: string
noindex?: boolean
pageType?: WebPageType
breadcrumbs?: Crumb[]
mainEntityId?: string
extraJsonLd?: (JsonLdNode | null | undefined)[]
}
const {
@@ -37,21 +31,15 @@ const {
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
pageType,
breadcrumbs,
mainEntityId,
extraJsonLd,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const canonicalURL = new URL(url)
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
@@ -70,21 +58,28 @@ const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
const structuredData = noindex
? undefined
: buildPageGraph(
{ siteUrl, locale },
{
url,
name: title,
description,
imageUrl: ogImageURL.href,
type: pageType,
crumbs: breadcrumbs,
mainEntityId,
},
...(extraJsonLd ?? []),
)
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org',
logo: 'https://comfy.org/icons/logomark.svg',
sameAs: [
'https://github.com/comfyanonymous/ComfyUI',
'https://discord.gg/comfyorg',
'https://x.com/comaboratory',
'https://reddit.com/r/comfyui',
'https://linkedin.com/company/comfyorg',
'https://instagram.com/comfyorg',
],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Comfy',
url: 'https://comfy.org',
}
---
<!doctype html>
@@ -126,7 +121,10 @@ const structuredData = noindex
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
{structuredData && <JsonLdGraph graph={structuredData} />}
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
<slot name="head" />
<!-- Google Tag Manager -->
@@ -146,6 +144,7 @@ const structuredData = noindex
)}
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (

View File

@@ -5,25 +5,9 @@ import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="About Us — Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.about', locale) },
]}
>
<BaseLayout title="About Us — Comfy">
<HeroSection client:load />
<StorySection />
<OurValuesSection />

View File

@@ -9,36 +9,34 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
const pageTitle = t('affiliate.page.title', 'en')
const pageDescription = t('affiliate.page.description', 'en')
const { locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const faqPage: JsonLdNode = {
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
})),
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
}
---
<BaseLayout
title={pageTitle}
description={pageDescription}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: pageTitle },
]}
extraJsonLd={[faqPage]}
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection />
<HowItWorksSection />

View File

@@ -7,13 +7,6 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
import { t } from '../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -26,31 +19,11 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="Careers — Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection />
<RolesSection departments={departments} client:visible />

View File

@@ -2,41 +2,9 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="Pricing — Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<BaseLayout title="Pricing — Comfy Cloud">
<PriceSection client:load />
<WhatsIncludedSection />
</BaseLayout>

View File

@@ -4,44 +4,39 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../i18n/translations'
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const title = t('cloudNodes.meta.title', 'en')
const description = t('cloudNodes.meta.description', 'en')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Custom-node packs supported on Comfy Cloud',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
})),
)
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,12 +7,7 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -34,45 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
description: pack.description || undefined,
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
authorName: pack.publisher?.name,
isFree: true,
})
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -2,25 +2,9 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import FormSection from '../components/contact/FormSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Contact — Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.contact', locale) },
]}
>
<BaseLayout title="Contact — Comfy">
<FormSection client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -7,13 +7,6 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -26,34 +19,68 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
@@ -61,20 +88,25 @@ const learningResource: JsonLdNode = {
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -8,29 +8,11 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Download Comfy Desktop — Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -9,28 +9,11 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from "../utils/jsonLd";
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
);
---
<BaseLayout
title="Comfy — Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[
comfyUiApplicationNode(siteUrl),
comfyUiSourceCodeNode(siteUrl),
]}
keywords={[
"comfyui app",
"comfyui web app",

View File

@@ -4,13 +4,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
@@ -26,6 +19,7 @@ if (model.canonicalSlug) {
}
const { displayName } = model
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
const dirDescriptions: Record<string, string> = {
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
@@ -46,31 +40,55 @@ const dirDescriptions: Record<string, string> = {
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: displayName,
url,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Any',
})
const faqPage: JsonLdNode = {
url: canonicalURL.href,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('models.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('models.breadcrumb.models'),
item: 'https://comfy.org/p/supported-models'
},
{
'@type': 'ListItem',
position: 3,
name: displayName
}
]
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
},
{
'@type': 'Question',
@@ -79,44 +97,54 @@ const faqPage: JsonLdNode = {
'@type': 'Answer',
text: model.docsUrl
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
},
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
}
},
{
'@type': 'Question',
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
},
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
}
},
{
'@type': 'Question',
name: `Is ${displayName} free to use in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
},
},
],
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
}
}
]
}
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
---
<BaseLayout
title={`${pageTitle} — Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('models.breadcrumb.models', locale),
url: absoluteUrl(Astro.site, '/p/supported-models'),
},
{ name: displayName },
]}
extraJsonLd={[software, faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(softwareAppJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<ModelHeroSection
displayName={displayName}

View File

@@ -2,29 +2,10 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const modelList = itemListNode(
url,
title,
models.map((model) => ({
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
})),
)
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
@@ -45,13 +26,6 @@ const dirLabel: Record<string, string> = {
<BaseLayout
title={`${title} — Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: title },
]}
extraJsonLd={[modelList]}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">

View File

@@ -5,29 +5,9 @@ import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="关于我们 — Comfy"
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.about', locale) },
]}
>
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />

View File

@@ -7,13 +7,6 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -26,34 +19,11 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="招聘 — Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" departments={departments} client:visible />

View File

@@ -2,44 +2,9 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PriceSection from '../../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../../config/pricing'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="定价 — Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<BaseLayout title="定价 — Comfy Cloud">
<PriceSection locale="zh-CN" client:load />
<WhatsIncludedSection locale="zh-CN" />
</BaseLayout>

View File

@@ -4,47 +4,39 @@ import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const title = t('cloudNodes.meta.title', 'zh-CN')
const description = t('cloudNodes.meta.description', 'zh-CN')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Comfy Cloud 支持的自定义节点包',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
})),
)
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
title={t('cloudNodes.meta.title', 'zh-CN')}
description={t('cloudNodes.meta.description', 'zh-CN')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection locale="zh-CN" client:visible />
<PackGridSection locale="zh-CN" packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,12 +7,7 @@ import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../../utils/jsonLd'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -34,48 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
description: pack.description || undefined,
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
authorName: pack.publisher?.name,
isFree: true,
})
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} locale="zh-CN" />
</BaseLayout>

View File

@@ -2,28 +2,9 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import FormSection from '../../components/contact/FormSection.vue'
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="联系我们 — Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.contact', locale) },
]}
>
<BaseLayout title="联系我们 — Comfy">
<FormSection locale="zh-CN" client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -7,13 +7,6 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -26,34 +19,68 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title, 'zh-CN')
const description = t(demo.description, 'zh-CN')
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
: 'Advanced',
url: canonicalURL.href,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('demos.breadcrumb.home', 'zh-CN'),
item: 'https://comfy.org/zh-CN'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos', 'zh-CN'),
item: 'https://comfy.org/zh-CN/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
}
---
@@ -61,23 +88,25 @@ const learningResource: JsonLdNode = {
title={`${title} — Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -8,32 +8,11 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />

View File

@@ -9,25 +9,11 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
import { t } from '../../i18n/translations'
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Comfy — 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
>
<HeroSection locale="zh-CN" client:load />

View File

@@ -1,212 +0,0 @@
import { describe, expect, it } from 'vitest'
import { externalLinks } from '../config/routes'
import { escapeJsonLd } from './escapeJsonLd'
import type { JsonLdGraph } from './jsonLd'
import {
absoluteUrl,
buildPageGraph,
collectGraphIds,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
itemListNode,
jsonLdId,
organizationId,
pageContext,
productNode,
softwareApplicationNode
} from './jsonLd'
const siteUrl = 'https://comfy.org'
const site = new URL('https://comfy.org/')
function typeNames(graph: JsonLdGraph): string[] {
return graph['@graph'].map((node) => node['@type'])
}
describe('absoluteUrl', () => {
it('resolves internal paths to their trailing-slash canonical form', () => {
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
})
})
describe('pageContext', () => {
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
expect(pageContext(site, '/about/', undefined)).toEqual({
siteUrl,
locale: 'en',
url: 'https://comfy.org/about/'
})
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
})
})
describe('itemListNode', () => {
it('counts items and omits per-item names when not supplied', () => {
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
{ url: 'https://jobs.example/1' },
{ url: 'https://jobs.example/2', name: 'Designer' }
])
expect(node.numberOfItems).toBe(2)
const items = node.itemListElement as Record<string, unknown>[]
expect('name' in items[0]).toBe(false)
expect(items[1].name).toBe('Designer')
})
})
describe('softwareApplicationNode', () => {
it('claims Comfy Org as author and publisher only when first-party', () => {
const node = softwareApplicationNode({
siteUrl,
id: jsonLdId(siteUrl, 'software'),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
isFree: true
})
const orgRef = { '@id': organizationId(siteUrl) }
expect(node.author).toEqual(orgRef)
expect(node.publisher).toEqual(orgRef)
expect(node.offers).toEqual({
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: orgRef
})
})
it('does not name Comfy Org as seller on a third-party free offer', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
isFree: true
})
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
})
it('credits a known third-party author without claiming to publish it', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
authorName: 'Jane Dev'
})
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
expect(node.publisher).toBeUndefined()
})
it('claims no author or publisher for third-party software with no author', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.author).toBeUndefined()
expect(node.publisher).toBeUndefined()
})
})
describe('sameAs encyclopedic references', () => {
it('links the organization to its Wikidata entity', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/`, name: 'Home' }
)
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
})
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
const node = comfyUiApplicationNode(siteUrl)
expect(node.sameAs).toEqual([
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
])
})
it('omits sameAs for third-party software', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.sameAs).toBeUndefined()
})
})
describe('productNode', () => {
it('gives every offer a currency and price', () => {
const node = productNode({
siteUrl,
id: 'https://comfy.org/cloud/pricing/#product',
name: 'Comfy Cloud',
url: 'https://comfy.org/cloud/pricing/',
offers: [{ name: 'Standard', price: '20' }]
})
const offers = node.offers as Record<string, unknown>[]
expect(offers[0].price).toBe('20')
expect(offers[0].priceCurrency).toBe('USD')
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
})
})
describe('comfyUiSourceCodeNode', () => {
it('links the source code to the ComfyUI application via targetProduct', () => {
const node = comfyUiSourceCodeNode(siteUrl)
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
})
})
describe('buildPageGraph', () => {
const url = 'https://comfy.org/cloud/pricing/'
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{
url,
name: 'Pricing',
type: 'CollectionPage',
mainEntityId: jsonLdId(url, 'itemlist'),
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
},
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
)
it('always includes the site-wide organization, website and page entity', () => {
expect(typeNames(graph)).toContain('Organization')
expect(typeNames(graph)).toContain('WebSite')
expect(typeNames(graph)).toContain('CollectionPage')
})
it('produces a graph where every @id reference resolves', () => {
const { defined, references } = collectGraphIds(graph)
for (const reference of references) {
expect(defined.has(reference)).toBe(true)
}
})
})
describe('escapeJsonLd on a built graph', () => {
it('neutralizes a </script> breakout in a page name', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
)
const serialized = escapeJsonLd(graph)
expect(serialized).not.toContain('</script>')
expect(serialized).toContain('\\u003c')
})
})

View File

@@ -1,377 +0,0 @@
import { externalLinks } from '../config/routes'
import type { Locale } from '../i18n/translations'
export type JsonLdNode = Record<string, unknown> & { '@type': string }
export interface JsonLdGraph {
'@context': 'https://schema.org'
'@graph': JsonLdNode[]
}
export interface PageContext {
siteUrl: string
locale: Locale
}
export type WebPageType =
| 'WebPage'
| 'AboutPage'
| 'ContactPage'
| 'CollectionPage'
export interface Crumb {
name: string
url?: string
}
const sameAs = [
externalLinks.github,
externalLinks.x,
externalLinks.youtube,
externalLinks.discord,
externalLinks.instagram,
externalLinks.reddit,
externalLinks.linkedin,
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
externalLinks.wikidataComfyOrg
]
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
const comfyUiSameAs = [
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
]
function siteUrlFrom(site: URL | undefined): string {
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
}
export function absoluteUrl(site: URL | undefined, path: string): string {
const resolved = new URL(path, site ?? 'https://comfy.org').href
return resolved.endsWith('/') ? resolved : `${resolved}/`
}
export function pageContext(
site: URL | undefined,
pathname: string,
currentLocale: string | undefined
): PageContext & { url: string } {
return {
siteUrl: siteUrlFrom(site),
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
url: absoluteUrl(site, pathname)
}
}
export function jsonLdId(pageUrl: string, fragment: string): string {
return `${pageUrl}#${fragment}`
}
export function organizationId(siteUrl: string): string {
return `${siteUrl}/#organization`
}
function websiteId(siteUrl: string): string {
return `${siteUrl}/#website`
}
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
return {
'@context': 'https://schema.org',
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
}
}
function organizationNode(siteUrl: string): JsonLdNode {
return {
'@type': 'Organization',
'@id': organizationId(siteUrl),
name: 'Comfy Org',
url: siteUrl,
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/web-app-manifest-512x512.png`,
width: 512,
height: 512
},
sameAs
}
}
function websiteNode(siteUrl: string): JsonLdNode {
return {
'@type': 'WebSite',
'@id': websiteId(siteUrl),
name: 'Comfy',
url: siteUrl,
publisher: { '@id': organizationId(siteUrl) }
}
}
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
return {
'@type': 'BreadcrumbList',
'@id': jsonLdId(pageUrl, 'breadcrumb'),
itemListElement: crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return isLast || !crumb.url
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
: {
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: crumb.url
}
})
}
}
export function itemListNode(
pageUrl: string,
name: string,
items: { url: string; name?: string }[]
): JsonLdNode {
return {
'@type': 'ItemList',
'@id': jsonLdId(pageUrl, 'itemlist'),
name,
numberOfItems: items.length,
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
url: item.url,
...(item.name ? { name: item.name } : {})
}))
}
}
interface WebPageInput {
siteUrl: string
locale: Locale
url: string
name: string
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
return {
'@type': type,
'@id': jsonLdId(input.url, 'webpage'),
url: input.url,
name: input.name,
description: input.description,
isPartOf: { '@id': websiteId(input.siteUrl) },
primaryImageOfPage: input.imageUrl
? { '@type': 'ImageObject', url: input.imageUrl }
: undefined,
breadcrumb: hasCrumbs
? { '@id': jsonLdId(input.url, 'breadcrumb') }
: undefined,
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
inLanguage: input.locale
}
}
export interface SoftwareAppInput {
siteUrl: string
id: string
name: string
url: string
applicationCategory: string
firstParty?: boolean
applicationSubCategory?: string
description?: string
operatingSystem?: string
image?: string
softwareVersion?: string
license?: string
codeRepository?: string
authorName?: string
isFree?: boolean
sameAs?: string[]
}
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
const orgRef = { '@id': organizationId(input.siteUrl) }
const author = input.firstParty
? orgRef
: input.authorName
? { '@type': 'Person', name: input.authorName }
: undefined
return {
'@type': 'SoftwareApplication',
'@id': input.id,
name: input.name,
url: input.url,
applicationCategory: input.applicationCategory,
applicationSubCategory: input.applicationSubCategory,
description: input.description,
operatingSystem: input.operatingSystem,
image: input.image,
softwareVersion: input.softwareVersion,
license: input.license,
codeRepository: input.codeRepository,
author,
publisher: input.firstParty ? orgRef : undefined,
sameAs: input.sameAs,
offers: input.isFree
? {
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: input.firstParty ? orgRef : undefined
}
: undefined
}
}
interface SourceCodeInput {
siteUrl: string
id: string
name: string
codeRepository: string
programmingLanguage?: string
targetProductId?: string
}
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
return {
'@type': 'SoftwareSourceCode',
'@id': input.id,
name: input.name,
codeRepository: input.codeRepository,
programmingLanguage: input.programmingLanguage,
targetProduct: input.targetProductId
? { '@id': input.targetProductId }
: undefined,
author: { '@id': organizationId(input.siteUrl) }
}
}
export function comfyUiSoftwareId(siteUrl: string): string {
return `${siteUrl}/#software`
}
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
return softwareApplicationNode({
siteUrl,
id: comfyUiSoftwareId(siteUrl),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux',
isFree: true,
sameAs: comfyUiSameAs
})
}
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
return softwareSourceCodeNode({
siteUrl,
id: `${siteUrl}/#sourcecode`,
name: 'ComfyUI',
codeRepository: externalLinks.github,
programmingLanguage: 'Python',
targetProductId: comfyUiSoftwareId(siteUrl)
})
}
interface OfferInput {
name: string
price: string | number
url?: string
}
export interface ProductInput {
siteUrl: string
id: string
name: string
url: string
offers: OfferInput[]
}
export function productNode(input: ProductInput): JsonLdNode {
return {
'@type': 'Product',
'@id': input.id,
name: input.name,
url: input.url,
brand: { '@id': organizationId(input.siteUrl) },
offers: input.offers.map((offer) => ({
'@type': 'Offer',
name: offer.name,
price: offer.price,
priceCurrency: 'USD',
url: offer.url,
seller: { '@id': organizationId(input.siteUrl) },
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: offer.price,
priceCurrency: 'USD',
unitText: 'MONTH'
}
}))
}
}
export interface PageGraphInput {
url: string
name: string
type?: WebPageType
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
export function buildPageGraph(
ctx: PageContext,
page: PageGraphInput,
...extraNodes: (JsonLdNode | null | undefined)[]
): JsonLdGraph {
const { type = 'WebPage', ...rest } = page
const input: WebPageInput = {
...rest,
siteUrl: ctx.siteUrl,
locale: ctx.locale
}
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
return buildGraph(
organizationNode(ctx.siteUrl),
websiteNode(ctx.siteUrl),
webPageNode(input, type),
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
...extraNodes
)
}
export function collectGraphIds(value: unknown): {
defined: Set<string>
references: string[]
} {
const defined = new Set<string>()
const references: string[] = []
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (node && typeof node === 'object') {
const record = node as Record<string, unknown>
const id = record['@id']
if (typeof id === 'string') {
if (Object.keys(record).length === 1) references.push(id)
else defined.add(id)
}
Object.values(record).forEach(walk)
}
}
walk(value)
return { defined, references }
}

View File

@@ -1,279 +0,0 @@
import { expect } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
* add a provider key, see it listed, delete it — the full CRUD round-trip —
* plus the entitlement contract that a non-entitled account never sees the
* gated providers.
*
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
* `/secrets` backend on top so the flow is deterministic and never touches a
* real server.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// `/api/features` is the remote-config source. Enabling user secrets is what
// surfaces the Secrets settings panel for a signed-in user.
const BOOT_FEATURES = {
user_secrets_enabled: true
} satisfies RemoteConfig
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
// The plaintext key a user types in. It must be sent on create but NEVER echoed
// back by the API or rendered anywhere in the UI.
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
interface SecretRecord {
id: string
name: string
provider?: string
created_at: string
updated_at: string
last_used_at?: string
}
interface CreateCapture {
name?: string
provider?: string
secret_value?: string
}
interface SecretsBackend {
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
createRequests: CreateCapture[]
/** Current server-side store — for asserting delete actually removed a row. */
store: SecretRecord[]
}
/**
* Stateful mock of the ingest `/secrets` surface. A single route handler
* branches on path + method so registration order can never make a specific
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
*
* `providerIds` models entitlement: an entitled account sees runway/gemini,
* a non-entitled account gets an empty list (the server omits them).
*/
async function mockSecretsBackend(
page: Page,
providerIds: string[]
): Promise<SecretsBackend> {
const backend: SecretsBackend = { createRequests: [], store: [] }
let idSeq = 0
const respondList = (route: Route) =>
route.fulfill(jsonRoute({ data: backend.store }))
await page.route('**/api/secrets**', async (route) => {
const request = route.request()
const { pathname } = new URL(request.url())
const method = request.method()
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
// contains the `/api/secrets` substring. Fulfilling that dev-server module
// request with JSON breaks the dynamic import and the panel never mounts.
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
// routes are handled; everything else falls through to the real Vite server.
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
return route.continue()
}
// GET /secrets/providers — the entitlement-gated provider allowlist.
if (pathname.endsWith('/secrets/providers')) {
return route.fulfill(
jsonRoute({ data: providerIds.map((id) => ({ id })) })
)
}
// /secrets/:id — item routes (only DELETE is exercised by this flow).
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
if (itemMatch) {
const id = itemMatch[1]
if (method === 'DELETE') {
backend.store = backend.store.filter((s) => s.id !== id)
return route.fulfill({ status: 204, body: '' })
}
return respondList(route)
}
// /secrets — collection routes.
if (method === 'POST') {
const body = (request.postDataJSON() ?? {}) as CreateCapture
backend.createRequests.push(body)
idSeq += 1
const created: SecretRecord = {
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
name: body.name ?? '',
provider: body.provider,
created_at: '2026-07-08T00:00:00Z',
updated_at: '2026-07-08T00:00:00Z'
}
backend.store.push(created)
// Response echoes metadata ONLY — the schema has no secret_value field.
return route.fulfill(jsonRoute(created))
}
// GET /secrets (list).
return respondList(route)
})
return backend
}
/**
* Open the settings dialog and land on the Secrets panel, waiting for both the
* provider allowlist and the secret list to resolve so subsequent assertions
* are not racing the panel's on-mount fetches.
*/
async function openSecretsPanel(page: Page) {
const settingsDialog = page.getByTestId('settings-dialog')
await page.evaluate(() => {
const app = window.app
if (!app) throw new Error('window.app is not available')
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
})
await settingsDialog.waitFor({ state: 'visible' })
const providersResolved = page.waitForResponse((r) =>
r.url().includes('/api/secrets/providers')
)
const listResolved = page.waitForResponse(
(r) =>
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
)
await settingsDialog
.locator('nav')
.getByRole('button', { name: 'Secrets' })
.click()
await Promise.all([providersResolved, listResolved])
return settingsDialog
}
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
test('an entitled account can add, list, and delete a provider key', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
// Empty state before anything is added.
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// --- ADD -------------------------------------------------------------
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
// Pick the entitled Runway provider from the server-driven dropdown.
await formDialog.locator('#secret-provider').click()
await page.getByRole('option', { name: 'Runway' }).click()
await formDialog.locator('#secret-name').fill('My Runway Key')
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
await expect(formDialog).toBeHidden()
// --- LIST ------------------------------------------------------------
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
// The create request carried the plaintext value + provider...
expect(backend.createRequests).toHaveLength(1)
expect(backend.createRequests[0]).toMatchObject({
name: 'My Runway Key',
provider: 'runway',
secret_value: RUNWAY_KEY_VALUE
})
// ...but the value must never be echoed back into the list — the API
// response carries metadata only, so nothing should render it as text.
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
// --- DELETE ----------------------------------------------------------
await settingsDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
const confirmDialog = page
.getByRole('dialog')
.filter({ hasText: 'Delete Secret' })
await confirmDialog
.getByRole('button', { name: 'Delete', exact: true })
.click()
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
expect(backend.store).toHaveLength(0)
})
test('a non-entitled account never sees the gated providers', async ({
page
}) => {
test.slow()
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await bootCloud(page)
// Non-entitled: the server omits runway/gemini from the allowlist.
await mockSecretsBackend(page, [])
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
const settingsDialog = await openSecretsPanel(page)
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
// The add form opens, but its provider dropdown is empty — the gated
// providers must not appear anywhere.
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
const formDialog = page
.getByRole('dialog')
.filter({ hasText: 'Secret Value' })
await expect(formDialog).toBeVisible()
await formDialog.locator('#secret-provider').click()
// Anchor on the opened listbox so the absence assertions below can't pass
// vacuously against a dropdown that never opened.
const providerListbox = page.getByRole('listbox')
await expect(providerListbox).toBeVisible()
// An empty allowlist must yield an empty dropdown. Asserting zero options
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
// where `availableProviders` is null and the default providers would show.
await expect(providerListbox.getByRole('option')).toHaveCount(0)
})
})

View File

@@ -1,13 +1,7 @@
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
@@ -45,34 +39,4 @@ test.describe('Preview as Text node', () => {
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
wstest(
'restoring workflow restores state',
{ tag: '@vue-nodes' },
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview as Text')
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
const preview = node.root.locator('textarea')
await test.step('node previews execution result', async () => {
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
execution.executed('', id, { text: 'massive fennec ears' })
await expect(preview).toHaveValue('massive fennec ears')
})
await test.step('swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.getTab(0).click()
await expect(node.root).toBeHidden()
await comfyPage.menu.topbar.getTab(1).click()
await expect(node.root).toBeVisible()
})
await expect(preview, 'previous output is restored').toHaveValue(
'massive fennec ears'
)
}
)
})

View File

@@ -54,11 +54,6 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Pending integration in the workspace-settings stacked PRs: consumed by
// split/auto-reload + split/allowlist (Switch) and split/member-auditing
// + split/allowlist (Pagination); each consumer removes its entry
'src/components/ui/switch/Switch.vue',
'src/components/ui/pagination/Pagination.vue',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -12,21 +11,15 @@ import { computed, ref, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import { cn } from '@comfyorg/tailwind-utils'
import type { ButtonVariants } from '../ui/button/button.variants'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
defineOptions({
inheritAttrs: false
})
const {
itemClass: itemProp,
contentClass: contentProp,
modal = true
} = defineProps<{
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
@@ -34,7 +27,6 @@ const {
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
modal?: boolean
}>()
const itemClass = computed(() =>
@@ -51,19 +43,12 @@ const contentClass = computed(() =>
)
)
// Body-portaled content keeps its static z-1700 unless a dialog that joined
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
// past that dialog so the menu isn't hidden behind it.
const open = ref(false)
const contentStyle = computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
const contentStyle = useModalLiftedZIndex(open)
</script>
<template>
<DropdownMenuRoot v-model:open="open" :modal>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">

View File

@@ -1,40 +0,0 @@
<template>
<div class="relative mx-2">
<div
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
>
<Button
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
variant="inverted"
size="icon-lg"
type="button"
:aria-label="deselectLabel"
class="rounded-lg hover:bg-base-background/10"
@click="emit('deselect')"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
{{ label }}
</span>
<div class="ml-auto flex shrink-0 items-center gap-1">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
defineProps<{
/** The "N selected" text; the caller formats it (pluralization, wording). */
label: string
/** Accessible label + tooltip for the deselect button. */
deselectLabel: string
}>()
const emit = defineEmits<{
deselect: []
}>()
</script>

View File

@@ -449,12 +449,6 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
expect(event.defaultPrevented).toBe(true)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')

View File

@@ -32,9 +32,7 @@
dialogStore.activeKey === item.key
)
"
@focus-outside="
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
"
@focus-outside="onRekaFocusOutside"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<template v-if="item.dialogComponentProps.headless">

View File

@@ -12,7 +12,7 @@ const PRIMEVUE_OVERLAY_SELECTORS =
// dismiss itself. These selectors cover the portaled roots so we can treat
// interactions on them as inside.
const REKA_PORTAL_SELECTORS =
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
@@ -53,22 +53,7 @@ export function onRekaPointerDownOutside(
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
//
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
// also lose focus to an ordinary app element — not just a portal — when a
// nested dialog closes and the element it focused was removed (deleting the
// selected row). That programmatic focus shift is not a dismiss intent, so such
// a dialog opts out of focus-outside dismissal entirely via
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
// pointer.
export function onRekaFocusOutside(
event: OutsideEvent,
options: { dismissOnFocusOutside?: boolean } = {}
) {
if (options.dismissOnFocusOutside === false) {
event.preventDefault()
return
}
export function onRekaFocusOutside(event: OutsideEvent) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}

View File

@@ -1,7 +1,10 @@
<template>
<div class="flex h-full flex-col">
<!-- Assets Grid -->
<!-- key on gridMode remounts the virtualizer so it re-measures cell size
when switching density (it caches item height/width otherwise). -->
<VirtualGrid
:key="gridMode"
class="flex-1"
:items="assetItems"
:grid-style="gridStyle"
@@ -28,13 +31,22 @@ import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { gridColumnsForMode } from '@/platform/assets/components/mediaAssetViewOptions'
import type { MediaAssetViewMode } from '@/platform/assets/components/mediaAssetViewOptions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
const {
assets,
isSelected,
showOutputCount,
getOutputCount,
gridMode = 'grid-small'
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
gridMode?: MediaAssetViewMode
}>()
const emit = defineEmits<{
@@ -54,10 +66,10 @@ const assetItems = computed<AssetGridItem[]>(() =>
}))
)
const gridStyle = {
const gridStyle = computed(() => ({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',
gridTemplateColumns: gridColumnsForMode(gridMode),
padding: '0 0.5rem',
gap: '0.5rem'
}
}))
</script>

View File

@@ -39,6 +39,7 @@
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
v-model:date-filter="dateFilter"
bottom-divider
:show-generation-time-sort="activeTab === 'output'"
/>
@@ -85,6 +86,7 @@
</div>
<div
v-else
ref="assetPaneRef"
class="relative size-full py-2"
@click="handleEmptySpaceClick"
>
@@ -106,6 +108,7 @@
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
:grid-mode="viewMode"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@@ -180,9 +183,13 @@ import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
import type { MediaAssetViewMode } from '@/platform/assets/components/mediaAssetViewOptions'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import {
shouldInterceptSelectAll,
useAssetSelection
} from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
@@ -215,11 +222,16 @@ const folderJobId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const expectedFolderCount = ref(0)
const isInFolderView = computed(() => folderJobId.value !== null)
const viewMode = useStorage<'list' | 'grid'>(
const viewMode = useStorage<MediaAssetViewMode>(
'Comfy.Assets.Sidebar.ViewMode',
'grid'
'grid-small'
)
// Migrate the pre-split legacy 'grid' value to the dense grid.
if (!['list', 'grid-small', 'grid-large'].includes(viewMode.value)) {
viewMode.value = 'grid-small'
}
const isListView = computed(() => viewMode.value === 'list')
const assetPaneRef = ref<HTMLElement>()
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
@@ -260,6 +272,7 @@ const outputAssets = useAssetsApi('output')
const {
isSelected,
handleAssetClick,
selectAll,
hasSelection,
clearSelection,
getSelectedAssets,
@@ -316,7 +329,7 @@ const baseAssets = computed(() => {
})
// Use media asset filtering composable
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
const { searchQuery, sortBy, mediaTypeFilters, dateFilter, filteredAssets } =
useMediaAssetFiltering(baseAssets)
const displayAssets = computed(() => {
@@ -418,8 +431,10 @@ watch(
activeTab,
() => {
clearSelection()
// Clear search when switching tabs
// Clear search + filters when switching tabs so no stale filter hides results.
searchQuery.value = ''
mediaTypeFilters.value = []
dateFilter.value = ''
// Reset pagination state when tab changes
void refreshAssets()
},
@@ -562,12 +577,27 @@ const exitFolderView = () => {
searchQuery.value = ''
}
function handleSelectAllKeydown(event: KeyboardEvent) {
if (!shouldInterceptSelectAll(event, assetPaneRef.value)) return
// Stop the event before the canvas keybinding handler runs: it is a
// bubble-phase window listener whose Comfy.Canvas.SelectAll is scoped to
// graph-canvas-container, which the asset pane lives inside — so without this
// it would also select every node. Capture phase beats the bubble listener.
event.preventDefault()
event.stopImmediatePropagation()
selectAll(visibleAssets.value)
}
onMounted(() => {
activateSelection()
window.addEventListener('keydown', handleSelectAllKeydown, { capture: true })
})
onUnmounted(() => {
deactivateSelection()
window.removeEventListener('keydown', handleSelectAllKeydown, {
capture: true
})
})
const handleDeselectAll = () => {

View File

@@ -1,72 +0,0 @@
<template>
<PaginationRoot
:page="page"
:total="total"
:items-per-page="itemsPerPage"
:sibling-count="1"
show-edges
@update:page="(p: number) => emit('update:page', p)"
>
<div class="flex items-center gap-1">
<PaginationPrev as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
<i class="icon-[lucide--chevron-left] size-4" />
{{ $t('g.previous') }}
</Button>
</PaginationPrev>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<template v-for="(item, index) in items" :key="index">
<PaginationListItem
v-if="item.type === 'page'"
:value="item.value"
as-child
>
<Button
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
size="icon"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
</PaginationEllipsis>
</template>
</PaginationList>
<PaginationNext as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
{{ $t('g.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
</PaginationNext>
</div>
</PaginationRoot>
</template>
<script setup lang="ts">
import {
PaginationEllipsis,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot
} from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
const {
page = 1,
total,
itemsPerPage = 10
} = defineProps<{
page?: number
total: number
itemsPerPage?: number
}>()
const emit = defineEmits<{ 'update:page': [page: number] }>()
const ellipsisClass =
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
</script>

View File

@@ -35,7 +35,7 @@ export const searchInputSizeConfig = {
icon: 'size-4',
iconPos: 'left-2.5',
inputPl: 'pl-8',
inputText: 'text-sm',
inputText: 'text-xs',
clearPos: 'left-2.5'
},
xl: {

View File

@@ -1,30 +0,0 @@
<template>
<SwitchRoot
v-model="checked"
:disabled
:class="
cn(
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-primary' : 'bg-interface-stroke'
)
"
>
<SwitchThumb
:class="
cn(
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
checked ? 'translate-x-3.5' : 'translate-x-0'
)
"
/>
</SwitchRoot>
</template>
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const { disabled = false } = defineProps<{ disabled?: boolean }>()
const checked = defineModel<boolean>({ default: false })
</script>

View File

@@ -9,9 +9,7 @@ import {
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
useExtensionService().registerExtension({
name: 'Comfy.PreviewAny',
@@ -32,11 +30,5 @@ useExtensionService().registerExtension({
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
},
onNodeOutputsUpdated(nodeOutputs) {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
if (node?.type === 'PreviewAny') updateTextPreviewWidgets(node, output)
}
}
})

View File

@@ -893,6 +893,8 @@
"title": "Media Assets",
"sortNewestFirst": "Newest first",
"sortOldestFirst": "Oldest first",
"sortAToZ": "Name (A → Z)",
"sortZToA": "Name (Z → A)",
"sortLongestFirst": "Generation time (longest first)",
"sortFastestFirst": "Generation time (fastest first)",
"filterImage": "Image",
@@ -900,6 +902,20 @@
"filterAudio": "Audio",
"filter3D": "3D",
"filterText": "Text",
"filterBy": "Filter by...",
"filterNoMatches": "No matches",
"filterMediaType": "Media type",
"filterDate": "Date",
"filterGroupAttribute": "Attribute",
"dateAll": "All time",
"dateToday": "Today",
"datePastWeek": "Past 7 days",
"datePastMonth": "Past 30 days",
"dateThisYear": "This year",
"clearFilters": "Clear all",
"removeFilter": "Remove {label} filter",
"viewGridSmall": "Grid (small)",
"viewGridLarge": "Grid (large)",
"viewSettings": "View settings"
},
"backToAssets": "Back to all assets",
@@ -984,7 +1000,6 @@
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"viewList": "List view",
"viewGrid": "Grid view",
"running": "running",
"preview": "Preview",
"interruptAll": "Interrupt all running jobs",
@@ -3122,52 +3137,57 @@
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {
"title": "Let's get to know you",
"title": "Cloud Survey",
"placeholder": "Survey questions placeholder",
"intro": "A few quick questions so we can set up ComfyUI for you.",
"otherPlaceholder": "Tell us more",
"intro": "Help us tailor your ComfyUI experience.",
"errors": {
"chooseAnOption": "Please choose an option.",
"selectAtLeastOne": "Please select at least one option.",
"describeAnswer": "Please describe your answer.",
"answerTooLong": "Please keep your answer under {max} characters."
"describeAnswer": "Please describe your answer."
},
"steps": {
"usage": "How do you plan to use ComfyUI?",
"familiarity": "How familiar are you with ComfyUI?",
"intent": "What do you want to create with ComfyUI?",
"source": "Where did you hear about ComfyUI?"
},
"options": {
"usage": {
"personal": "Personal use",
"work": "Work",
"education": "Education (student or educator)"
},
"familiarity": {
"new": "New — never used it",
"starting": "Beginner — following tutorials",
"basics": "Intermediate — comfortable with basics",
"advanced": "Advanced — build and edit workflows",
"expert": "Expert — I help others"
},
"intent": {
"images": "Images",
"video": "Video",
"workflows": "Workflows and pipelines",
"apps_api": "Apps and APIs",
"exploring": "Just exploring",
"other": "Something else",
"otherPlaceholder": "What do you want to make?"
},
"experience": {
"new": "New to ComfyUI",
"some": "I know my way around",
"pro": "I'm a power user"
},
"focus": {
"workflows": "Custom workflows or pipelines",
"custom_nodes": "Custom nodes",
"pipelines": "Automated pipelines",
"products": "Products for others"
"videos": "Videos",
"images": "Images",
"3d_game": "3D assets / game assets",
"audio": "Audio / music",
"apps": "Simplified Apps from workflows",
"api": "API endpoints to run workflows",
"not_sure": "Not sure"
},
"source": {
"social": "Social media",
"friend": "A friend or colleague",
"search": "Web search",
"community": "A community or forum",
"other": "Somewhere else",
"otherPlaceholder": "Where did you find us?"
},
"source_social": {
"youtube": "YouTube",
"reddit": "Reddit",
"twitter": "X (Twitter)",
"twitter": "Twitter / X",
"instagram": "Instagram",
"tiktok": "TikTok",
"linkedin": "LinkedIn",
"discord": "Discord"
"friend": "Friend or colleague",
"search": "Google / search",
"newsletter": "Newsletter or blog",
"conference": "Conference or event",
"discord": "Discord / community",
"github": "GitHub",
"other": "Other"
}
}
},
@@ -3260,11 +3280,10 @@
"cloudForgotPassword_emailRequired": "Email is required",
"cloudForgotPassword_passwordResetSent": "Password reset sent",
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
"cloudSurvey_steps_intent": "What do you want to make?",
"cloudSurvey_steps_experience": "How well do you know ComfyUI?",
"cloudSurvey_steps_focus": "What are you building?",
"cloudSurvey_steps_source": "How did you find us?",
"cloudSurvey_steps_source_social": "Which platform?",
"cloudSurvey_steps_usage": "How do you plan to use ComfyUI?",
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
"assetBrowser": {
"allCategory": "All {category}",
"allModels": "All Models",
@@ -3478,8 +3497,8 @@
},
"selection": {
"selectedCount": "{count} selected",
"unselectCount": "Unselect {count}",
"multipleSelectedAssets": "Multiple assets selected",
"deselectAll": "Deselect all",
"downloadSelected": "Download",
"downloadSelectedAll": "Download all",
"deleteSelected": "Delete",

View File

@@ -11,6 +11,7 @@
: $t('assetBrowser.ariaLabel.loadingAsset')
"
:tabindex="loading ? -1 : 0"
:aria-pressed="selected"
:class="
cn(
'flex cursor-pointer flex-col overflow-hidden rounded-lg p-2 transition-colors duration-200',
@@ -54,20 +55,19 @@
<i class="icon-[lucide--trash-2] size-5" />
</LoadingOverlay>
<!-- Action buttons overlay (top-left) -->
<!-- Action buttons overlay (top-right) -->
<div
v-if="showActionsOverlay"
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
class="absolute top-2 right-2 flex flex-wrap justify-end gap-2"
>
<IconGroup background-class="bg-white">
<Button
v-if="canInspect"
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.zoom')"
@click.stop="handleZoomClick"
:aria-label="$t('mediaAsset.actions.download')"
@click.stop="asset && actions.downloadAssets([asset])"
>
<i class="icon-[lucide--zoom-in] size-4" />
<i class="icon-[lucide--download] size-4" />
</Button>
<Button
variant="overlay-white"
@@ -81,6 +81,15 @@
</Button>
</IconGroup>
</div>
<!-- Selected check (top-left) -->
<div
v-if="selected"
class="absolute top-2 left-2 flex size-6 items-center justify-center rounded-full bg-white text-black shadow-sm"
aria-hidden="true"
>
<i class="icon-[lucide--check] size-4" />
</div>
</div>
<!-- Bottom Area: Media Info -->
@@ -101,34 +110,30 @@
</div>
<!-- Content -->
<div
v-else-if="asset && adaptedAsset"
class="flex items-end justify-between gap-1.5"
>
<!-- Left side: Media name and metadata -->
<div class="flex flex-col gap-1">
<!-- Title -->
<MediaTitle :file-name="fileName" />
<!-- Metadata -->
<div class="flex gap-1.5 text-xs text-muted-foreground">
<span v-if="formattedDuration">{{ formattedDuration }}</span>
<span v-if="metaInfo">{{ metaInfo }}</span>
<div v-else-if="asset && adaptedAsset" class="flex flex-col gap-1">
<!-- Title + output count -->
<div class="flex items-center justify-between gap-1.5">
<MediaTitle :file-name="fileName" class="min-w-0" />
<!-- Output count -->
<div v-if="showOutputCount" class="shrink-0">
<Button
v-tooltip.top.pt:pointer-events-none="
$t('mediaAsset.actions.seeMoreOutputs')
"
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
variant="secondary"
size="sm"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />
<span>{{ outputCount }}</span>
</Button>
</div>
</div>
<!-- Right side: Output count -->
<div v-if="showOutputCount" class="shrink-0">
<Button
v-tooltip.top.pt:pointer-events-none="
$t('mediaAsset.actions.seeMoreOutputs')
"
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
variant="secondary"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />
<span>{{ outputCount }}</span>
</Button>
<!-- File details -->
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span v-if="formattedDuration">{{ formattedDuration }}</span>
<span v-if="metaInfo">{{ metaInfo }}</span>
</div>
</div>
</div>
@@ -285,16 +290,24 @@ const displayImageDimensions = computed(() =>
resolveDisplayImageDimensions(asset, imageDimensions.value)
)
// Get metadata info based on file kind
const format = computed(() => {
const suffix = getFilenameDetails(asset?.name ?? '').suffix
return suffix ? suffix.toUpperCase() : ''
})
// Meta line: "FORMAT dimensions" for images, otherwise "FORMAT size".
const metaInfo = computed(() => {
if (!asset) return ''
const parts: string[] = []
if (format.value) parts.push(format.value)
if (fileKind.value === 'image' && displayImageDimensions.value) {
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
parts.push(
`${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
)
} else if (asset.size) {
parts.push(formatSize(asset.size))
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)
}
return ''
return parts.join(' ')
})
const showActionsOverlay = computed(() => {

View File

@@ -1,72 +1,118 @@
<template>
<SidebarTopArea :bottom-divider>
<SearchInput
:model-value="searchQuery"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('sideToolbar.labels.assets') })
"
@update:model-value="handleSearchChange"
<div>
<SidebarTopArea>
<MediaAssetSearchField
v-model="searchQuery"
:placeholder="
$t('g.searchPlaceholder', {
subject: $t('sideToolbar.labels.assets')
})
"
/>
<template #actions>
<MediaAssetFilterMenu
v-if="isCloud"
v-model:media-type-filters="mediaTypeFilters"
v-model:date-filter="dateFilter"
:active="hasActiveFilters"
/>
<MediaAssetSettingsButton
v-tooltip.top="{ value: $t('sideToolbar.mediaAssets.viewSettings') }"
>
<template #default>
<MediaAssetSettingsMenu
v-model:view-mode="viewMode"
v-model:sort-by="sortBy"
:show-sort-options="isCloud"
:show-generation-time-sort
/>
</template>
</MediaAssetSettingsButton>
</template>
</SidebarTopArea>
<MediaAssetFilterChips
v-if="filterChips.length"
:chips="filterChips"
class="px-2 pb-2 2xl:px-4"
@remove="removeChip"
@clear="clearAllFilters"
/>
<template #actions>
<MediaAssetFilterButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
>
<template #default="{ close }">
<MediaAssetFilterMenu
:media-type-filters
:close
@update:media-type-filters="handleMediaTypeFiltersChange"
/>
</template>
</MediaAssetFilterButton>
<MediaAssetSettingsButton
v-tooltip.top="{ value: $t('sideToolbar.mediaAssets.viewSettings') }"
>
<template #default>
<MediaAssetSettingsMenu
v-model:view-mode="viewMode"
v-model:sort-by="sortBy"
:show-sort-options="isCloud"
:show-generation-time-sort
/>
</template>
</MediaAssetSettingsButton>
</template>
</SidebarTopArea>
<div
v-if="bottomDivider"
class="border-t border-dashed border-comfy-input"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { isCloud } from '@/platform/distribution/types'
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
import {
DATE_VALUES,
MEDIA_TYPE_VALUES,
labelKeyForValue
} from './mediaAssetFilterFacets'
import type { MediaAssetViewMode } from './mediaAssetViewOptions'
import type { FilterChipDescriptor } from './MediaAssetFilterChips.vue'
import MediaAssetFilterChips from './MediaAssetFilterChips.vue'
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
import MediaAssetSearchField from './MediaAssetSearchField.vue'
import MediaAssetSettingsButton from './MediaAssetSettingsButton.vue'
import MediaAssetSettingsMenu from './MediaAssetSettingsMenu.vue'
import type { SortBy } from './MediaAssetSettingsMenu.vue'
const { showGenerationTimeSort = false, bottomDivider = false } = defineProps<{
searchQuery: string
showGenerationTimeSort?: boolean
mediaTypeFilters: string[]
bottomDivider?: boolean
}>()
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:mediaTypeFilters': [value: string[]]
}>()
const searchQuery = defineModel<string>('searchQuery', { required: true })
const sortBy = defineModel<SortBy>('sortBy', { required: true })
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const viewMode = defineModel<MediaAssetViewMode>('viewMode', { required: true })
const dateFilter = defineModel<string>('dateFilter', { required: true })
const mediaTypeFilters = defineModel<string[]>('mediaTypeFilters', {
required: true
})
const handleSearchChange = (value: string | undefined) => {
emit('update:searchQuery', value ?? '')
const { t } = useI18n()
const filterChips = computed<FilterChipDescriptor[]>(() => {
const chips: FilterChipDescriptor[] = mediaTypeFilters.value.map((type) => ({
key: `media:${type}`,
label: t(labelKeyForValue(MEDIA_TYPE_VALUES, type) ?? type)
}))
if (dateFilter.value) {
chips.push({
key: 'date',
label: t(
labelKeyForValue(DATE_VALUES, dateFilter.value) ?? dateFilter.value
)
})
}
return chips
})
const hasActiveFilters = computed(() => filterChips.value.length > 0)
function removeChip(key: string) {
if (key === 'date') {
dateFilter.value = ''
return
}
const type = key.slice('media:'.length)
mediaTypeFilters.value = mediaTypeFilters.value.filter(
(value) => value !== type
)
}
const handleMediaTypeFiltersChange = (value: string[]) => {
emit('update:mediaTypeFilters', value)
function clearAllFilters() {
dateFilter.value = ''
mediaTypeFilters.value = []
}
</script>

View File

@@ -1,23 +0,0 @@
<template>
<div class="inline-flex items-center">
<Popover>
<template #button>
<Button
variant="secondary"
size="icon"
:aria-label="$t('assetBrowser.filterBy')"
>
<i class="icon-[lucide--list-filter]" />
</Button>
</template>
<template #default="{ close }">
<slot :close />
</template>
</Popover>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
</script>

View File

@@ -0,0 +1,24 @@
<template>
<span
class="inline-flex items-center gap-1 rounded-md bg-secondary-background py-1 pr-1 pl-2 text-xs whitespace-nowrap"
>
<span>{{ label }}</span>
<Button
variant="textonly"
size="icon"
class="size-4 rounded-sm p-0 hover:bg-secondary-background-hover"
:aria-label="$t('sideToolbar.mediaAssets.removeFilter', { label })"
@click="emit('remove')"
>
<i class="icon-[lucide--x] size-3" />
</Button>
</span>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
defineProps<{ label: string }>()
const emit = defineEmits<{ remove: [] }>()
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="flex flex-wrap items-center gap-1.5">
<MediaAssetFilterChip
v-for="chip in chips"
:key="chip.key"
:label="chip.label"
@remove="emit('remove', chip.key)"
/>
<Button
variant="textonly"
class="h-6 px-1.5 text-xs text-muted-foreground"
@click="emit('clear')"
>
{{ $t('sideToolbar.mediaAssets.clearFilters') }}
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import MediaAssetFilterChip from './MediaAssetFilterChip.vue'
export interface FilterChipDescriptor {
key: string
label: string
}
defineProps<{ chips: FilterChipDescriptor[] }>()
const emit = defineEmits<{
remove: [key: string]
clear: []
}>()
</script>

View File

@@ -1,94 +1,122 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import MediaAssetFilterMenu from '@/platform/assets/components/MediaAssetFilterMenu.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderMenu(mediaTypeFilters: string[] = []) {
const onUpdate = vi.fn()
interface Overrides {
mediaTypeFilters?: string[]
dateFilter?: string
}
function renderMenu(overrides: Overrides = {}) {
const onMedia = vi.fn()
const onDate = vi.fn()
const utils = render(MediaAssetFilterMenu, {
props: {
mediaTypeFilters,
'onUpdate:mediaTypeFilters': onUpdate
mediaTypeFilters: overrides.mediaTypeFilters ?? [],
dateFilter: overrides.dateFilter ?? '',
'onUpdate:mediaTypeFilters': onMedia,
'onUpdate:dateFilter': onDate
},
global: {
mocks: {
$t: (key: string) => key
}
}
global: { plugins: [i18n] }
})
return { ...utils, onUpdate, user: userEvent.setup() }
return { ...utils, onMedia, onDate, user: userEvent.setup() }
}
const labelByType: Record<string, string> = {
image: 'sideToolbar.mediaAssets.filterImage',
video: 'sideToolbar.mediaAssets.filterVideo',
audio: 'sideToolbar.mediaAssets.filterAudio',
'3d': 'sideToolbar.mediaAssets.filter3D',
text: 'sideToolbar.mediaAssets.filterText'
const CAT = {
media: 'Media type',
date: 'Date'
}
function getCheckbox(type: keyof typeof labelByType): HTMLElement {
return screen.getByRole('checkbox', { name: labelByType[type] })
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Filter by' }))
}
function categoryItem(label: string) {
return screen.getByRole('menuitem', { name: new RegExp(label) })
}
describe('MediaAssetFilterMenu', () => {
it('renders all media-type checkboxes', () => {
renderMenu()
const checkboxes = screen.getAllByRole('checkbox')
expect(checkboxes).toHaveLength(5)
for (const type of Object.keys(labelByType)) {
expect(getCheckbox(type)).toBeTruthy()
}
it('lists every filter category', async () => {
const { user } = renderMenu()
await openMenu(user)
expect(categoryItem(CAT.media)).toBeTruthy()
expect(categoryItem(CAT.date)).toBeTruthy()
})
it('reflects checked state from the prop via aria-checked', () => {
renderMenu(['image', '3d'])
it('surfaces a matching value via flat search and applies it', async () => {
const { onMedia, user } = renderMenu()
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'video')
expect(getCheckbox('image').getAttribute('aria-checked')).toBe('true')
expect(getCheckbox('3d').getAttribute('aria-checked')).toBe('true')
expect(getCheckbox('video').getAttribute('aria-checked')).toBe('false')
expect(getCheckbox('audio').getAttribute('aria-checked')).toBe('false')
await user.click(screen.getByRole('menuitemcheckbox', { name: 'Video' }))
expect(onMedia).toHaveBeenCalledWith(['video'])
})
it('emits an array containing the new type when an unchecked box is clicked', async () => {
const { onUpdate, user } = renderMenu([])
await user.click(getCheckbox('video'))
it('applies a date preset via flat search', async () => {
const { onDate, user } = renderMenu()
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'today')
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledWith(['video'])
await user.click(screen.getByRole('menuitemcheckbox', { name: 'Today' }))
expect(onDate).toHaveBeenCalledWith('today')
})
it('emits an array without the type when a checked box is clicked again', async () => {
const { onUpdate, user } = renderMenu(['image', 'audio'])
await user.click(getCheckbox('audio'))
it('toggles a date preset off when it is already applied', async () => {
const { onDate, user } = renderMenu({ dateFilter: 'today' })
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'today')
expect(onUpdate).toHaveBeenCalledWith(['image'])
const row = screen.getByRole('menuitemcheckbox', { name: 'Today' })
expect(row).toHaveAttribute('aria-checked', 'true')
await user.click(row)
expect(onDate).toHaveBeenCalledWith('')
})
it('appends to the existing filter list rather than replacing it', async () => {
const { onUpdate, user } = renderMenu(['image'])
await user.click(getCheckbox('video'))
it('moves focus into the results with arrow keys after searching', async () => {
const { user } = renderMenu()
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'a')
expect(onUpdate).toHaveBeenCalledWith(['image', 'video'])
const results = screen.getAllByRole('menuitemcheckbox')
await user.keyboard('{ArrowDown}')
expect(results[0]).toHaveFocus()
screen.getByRole('textbox').focus()
await user.keyboard('{ArrowUp}')
expect(results[results.length - 1]).toHaveFocus()
})
it('toggles via keyboard (Enter and Space)', async () => {
const { onUpdate, user } = renderMenu([])
it('toggles a media value off when it is already applied', async () => {
const { onMedia, user } = renderMenu({ mediaTypeFilters: ['video'] })
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'video')
getCheckbox('image').focus()
await user.keyboard('{Enter}')
expect(onUpdate).toHaveBeenLastCalledWith(['image'])
const row = screen.getByRole('menuitemcheckbox', { name: 'Video' })
expect(row).toHaveAttribute('aria-checked', 'true')
getCheckbox('audio').focus()
await user.keyboard(' ')
expect(onUpdate).toHaveBeenLastCalledWith(['audio'])
await user.click(row)
expect(onMedia).toHaveBeenCalledWith([])
})
it('shows Clear all and resets every facet when a filter is applied', async () => {
const { onMedia, onDate, user } = renderMenu({
mediaTypeFilters: ['image'],
dateFilter: 'today'
})
await openMenu(user)
await user.click(screen.getByRole('menuitem', { name: 'Clear all' }))
expect(onMedia).toHaveBeenCalledWith([])
expect(onDate).toHaveBeenCalledWith('')
})
})

View File

@@ -1,72 +1,327 @@
<!--
TODO: Extract checkbox pattern into reusable Checkbox component
- Create src/components/input/Checkbox.vue with:
- Hidden native <input type="checkbox"> for accessibility
- Custom visual styling matching this implementation
- Semantic tokens (--primary-background, --input-surface, etc.)
- Use this Checkbox component in:
- MediaAssetFilterMenu.vue (this file)
- MultiSelect.vue option template
- SingleSelect.vue if needed
- Benefits: Consistent checkbox UI, better maintainability, reusable design system component
-->
<template>
<div class="m-0 flex flex-col gap-0 p-0">
<div
v-for="filter in filters"
:key="filter.type"
class="flex h-10 min-w-32 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-secondary-background-hover"
tabindex="0"
role="checkbox"
:aria-checked="mediaTypeFilters.includes(filter.type)"
@click="toggleMediaType(filter.type)"
@keydown.enter.prevent="toggleMediaType(filter.type)"
@keydown.space.prevent="toggleMediaType(filter.type)"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
mediaTypeFilters.includes(filter.type)
? 'border-primary-background bg-primary-background'
: 'bg-secondary-background'
"
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
class="relative"
:aria-label="$t('assetBrowser.filterBy')"
>
<i
v-if="mediaTypeFilters.includes(filter.type)"
class="icon-[lucide--check] text-xs font-bold text-white"
<i class="icon-[lucide--list-filter]" />
<span
v-if="active"
class="absolute top-1 right-1 size-1.5 rounded-full bg-primary-background"
/>
</div>
<span class="text-sm">{{ $t(filter.label) }}</span>
</div>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
align="start"
:side-offset="6"
:collision-padding="10"
:style="contentStyle"
:class="menuClass"
@open-auto-focus.prevent="focusSearch"
>
<div class="flex h-10 items-center gap-2 px-3">
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<input
ref="searchRef"
v-model="query"
type="text"
:placeholder="$t('sideToolbar.mediaAssets.filterBy')"
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
@keydown="onSearchKeydown"
/>
</div>
<DropdownMenuSeparator class="h-px bg-border-subtle" />
<div class="max-h-80 overflow-y-auto p-1">
<template v-if="query.trim()">
<template v-for="section in searchSections" :key="section.facet">
<DropdownMenuLabel :class="groupLabelClass">
{{ catLabel(section.facet) }}
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
v-for="row in section.rows"
:key="row.value"
:model-value="isApplied(section.facet, row.value)"
:class="rowClass"
@select.prevent="toggleFacetValue(section.facet, row.value)"
>
<i :class="cn(CAT_ICON[section.facet], iconClass)" />
<span :class="labelClass">{{ row.text }}</span>
<i
v-if="isApplied(section.facet, row.value)"
:class="checkClass"
/>
</DropdownMenuCheckboxItem>
</template>
<div
v-if="searchSections.length === 0"
class="px-2 py-1.5 text-sm text-muted-foreground"
>
{{ $t('sideToolbar.mediaAssets.filterNoMatches') }}
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<DropdownMenuLabel :class="groupLabelClass">
{{ $t(group.label) }}
</DropdownMenuLabel>
<DropdownMenuSub v-for="cat in group.cats" :key="cat">
<DropdownMenuSubTrigger
:class="
cn(
rowClass,
'data-[state=open]:bg-secondary-background-hover'
)
"
>
<i :class="cn(CAT_ICON[cat], iconClass)" />
<span :class="labelClass">{{ catLabel(cat) }}</span>
<span v-if="appliedCount(cat) > 0" :class="countClass">
{{ appliedCount(cat) }}
</span>
<i
class="icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground"
/>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
:side-offset="2"
:collision-padding="10"
:style="contentStyle"
:class="menuClass"
>
<div class="p-1">
<DropdownMenuCheckboxItem
v-for="opt in valuesFor(cat)"
:key="opt.value"
:model-value="isApplied(cat, opt.value)"
:class="rowClass"
@select.prevent="toggleFacetValue(cat, opt.value)"
>
<span :class="labelClass">{{ opt.text }}</span>
<i
v-if="isApplied(cat, opt.value)"
:class="checkClass"
/>
</DropdownMenuCheckboxItem>
</div>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</template>
</template>
</div>
<template v-if="anyApplied">
<DropdownMenuSeparator class="h-px bg-border-subtle" />
<DropdownMenuItem
class="flex h-10 items-center px-3 text-sm text-muted-foreground outline-none data-highlighted:text-base-foreground"
@select.prevent="clearAll"
>
{{ $t('sideToolbar.mediaAssets.clearFilters') }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
<script setup lang="ts">
const { mediaTypeFilters } = defineProps<{
mediaTypeFilters: string[]
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import { DATE_VALUES, MEDIA_TYPE_VALUES } from './mediaAssetFilterFacets'
import type { FacetValue } from './mediaAssetFilterFacets'
type FacetKey = 'media' | 'date'
interface ValueOption {
value: string
text: string
}
const { active = false } = defineProps<{
active?: boolean
}>()
const emit = defineEmits<{
'update:mediaTypeFilters': [value: string[]]
}>()
const mediaTypeFilters = defineModel<string[]>('mediaTypeFilters', {
required: true
})
const dateFilter = defineModel<string>('dateFilter', { required: true })
const filters = [
{ type: 'image', label: 'sideToolbar.mediaAssets.filterImage' },
{ type: 'video', label: 'sideToolbar.mediaAssets.filterVideo' },
{ type: 'audio', label: 'sideToolbar.mediaAssets.filterAudio' },
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' },
{ type: 'text', label: 'sideToolbar.mediaAssets.filterText' }
const { t } = useI18n()
const open = ref(false)
const contentStyle = useModalLiftedZIndex(open)
const searchRef = ref<HTMLInputElement>()
const query = ref('')
const menuClass =
'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade z-1700 flex w-56 flex-col rounded-lg border border-border-subtle bg-base-background shadow-sm will-change-[opacity,transform]'
const groupLabelClass =
'px-2 pt-2 pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase'
const rowClass =
'flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-sm outline-none data-highlighted:bg-secondary-background-hover'
const iconClass = 'size-4 shrink-0 text-muted-foreground'
const labelClass = 'min-w-0 flex-1 truncate text-left text-base-foreground'
const checkClass = 'icon-[lucide--check] size-4 shrink-0 text-base-foreground'
const countClass =
'flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-secondary-background px-1 text-xs text-muted-foreground tabular-nums'
const CAT_ICON: Record<FacetKey, string> = {
media: 'icon-[lucide--image]',
date: 'icon-[lucide--calendar]'
}
const CAT_LABEL: Record<FacetKey, string> = {
media: 'sideToolbar.mediaAssets.filterMediaType',
date: 'sideToolbar.mediaAssets.filterDate'
}
const catLabel = (cat: FacetKey) => t(CAT_LABEL[cat])
const groups: { key: string; label: string; cats: FacetKey[] }[] = [
{
key: 'attribute',
label: 'sideToolbar.mediaAssets.filterGroupAttribute',
cats: ['media', 'date']
}
]
const toggleMediaType = (type: string) => {
const isCurrentlySelected = mediaTypeFilters.includes(type)
if (isCurrentlySelected) {
emit(
'update:mediaTypeFilters',
mediaTypeFilters.filter((t) => t !== type)
)
} else {
emit('update:mediaTypeFilters', [...mediaTypeFilters, type])
// Values that clear a single-select facet — excluded from flat search results.
const CLEAR_VALUES = new Set(['', 'all'])
function toOptions(values: FacetValue[]): ValueOption[] {
return values.map((facetValue) => ({
value: facetValue.value,
text: t(facetValue.labelKey)
}))
}
function valuesFor(cat: FacetKey): ValueOption[] {
switch (cat) {
case 'media':
return toOptions(MEDIA_TYPE_VALUES)
case 'date':
return toOptions(DATE_VALUES)
}
}
const searchSections = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return []
return groups
.flatMap((g) => g.cats)
.map((facet) => ({
facet,
rows: valuesFor(facet).filter(
(o) => !CLEAR_VALUES.has(o.value) && o.text.toLowerCase().includes(q)
)
}))
.filter((s) => s.rows.length)
})
function isApplied(cat: FacetKey, value: string): boolean {
switch (cat) {
case 'media':
return mediaTypeFilters.value.includes(value)
case 'date':
return dateFilter.value === value
}
}
function appliedCount(cat: FacetKey): number {
switch (cat) {
case 'media':
return mediaTypeFilters.value.length
case 'date':
return dateFilter.value ? 1 : 0
}
}
function toggleFacetValue(cat: FacetKey, value: string) {
switch (cat) {
case 'date':
dateFilter.value = dateFilter.value === value ? '' : value
return
case 'media':
mediaTypeFilters.value = mediaTypeFilters.value.includes(value)
? mediaTypeFilters.value.filter((v) => v !== value)
: [...mediaTypeFilters.value, value]
}
}
const anyApplied = computed(
() => mediaTypeFilters.value.length > 0 || !!dateFilter.value
)
function clearAll() {
mediaTypeFilters.value = []
dateFilter.value = ''
}
function menuItems(fromEl: HTMLElement): HTMLElement[] {
const menu = fromEl.closest('[role="menu"]')
return menu
? Array.from(
menu.querySelectorAll<HTMLElement>(
'[role^="menuitem"]:not([aria-disabled="true"])'
)
)
: []
}
// The search box owns its keys: Down/Up hand focus into the results (reka drives
// nav from there), and printable keys stay in the box so reka's menu typeahead
// can't steal focus. Stopping propagation keeps global canvas keybindings out.
// Escape / Enter still reach the menu.
function onSearchKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const items = menuItems(event.currentTarget as HTMLElement)
if (!items.length) return
event.preventDefault()
event.stopPropagation()
const target =
event.key === 'ArrowDown' ? items[0] : items[items.length - 1]
target.focus()
return
}
if (
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
event.stopPropagation()
}
}
function focusSearch() {
query.value = ''
void nextTick(() => searchRef.value?.focus())
}
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div
class="focus-within:ring-secondary-foreground flex h-8 min-w-0 flex-1 items-center gap-2 rounded-lg bg-secondary-background px-3 focus-within:ring-1"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<input
v-model="model"
type="text"
:placeholder="placeholder"
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
/>
<Button
v-if="model"
variant="textonly"
size="icon-sm"
:aria-label="$t('g.clear')"
@click="model = ''"
>
<i class="icon-[lucide--x] size-3.5" />
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { placeholder = '' } = defineProps<{ placeholder?: string }>()
const model = defineModel<string>({ required: true })
</script>

View File

@@ -12,7 +12,7 @@ const i18n = createI18n({
en: {
mediaAsset: {
selection: {
deselectAll: 'Deselect all',
unselectCount: 'Unselect {count}',
downloadSelected: 'Download',
deleteSelected: 'Delete',
selectedCount: '{count} selected'
@@ -39,7 +39,7 @@ describe('MediaAssetSelectionBar', () => {
it('emits deselect when the close button is clicked', async () => {
const { emitted } = renderBar({ count: 2 })
await userEvent.click(screen.getByRole('button', { name: 'Deselect all' }))
await userEvent.click(screen.getByRole('button', { name: 'Unselect 2' }))
expect(emitted().deselect).toHaveLength(1)
})

View File

@@ -1,48 +1,67 @@
<template>
<SelectionBar
data-testid="assets-selection-bar"
:label="$t('mediaAsset.selection.selectedCount', { count })"
:deselect-label="$t('mediaAsset.selection.deselectAll')"
@deselect="emit('deselect')"
>
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.downloadSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-download-selected"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('download')"
<div class="relative mx-2">
<div
data-testid="assets-selection-bar"
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-if="showDelete">
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.deleteSelected'),
value: $t('mediaAsset.selection.unselectCount', { count }),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-delete-selected"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
data-testid="assets-deselect-selected"
:aria-label="$t('mediaAsset.selection.unselectCount', { count })"
class="rounded-lg hover:bg-base-background/10"
@click="emit('delete')"
@click="emit('deselect')"
>
<i class="icon-[lucide--trash-2] size-4" />
<i class="icon-[lucide--x] size-4" />
</Button>
</template>
</SelectionBar>
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
</span>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.downloadSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-download-selected"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('download')"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-if="showDelete">
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.deleteSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-delete-selected"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('delete')"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SelectionBar from '@/components/common/SelectionBar.vue'
import Button from '@/components/ui/button/Button.vue'
const { count, showDelete = true } = defineProps<{

View File

@@ -2,28 +2,41 @@ import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import MediaAssetSettingsMenu from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
import type { SortBy } from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const KEYS = {
list: 'sideToolbar.queueProgressOverlay.viewList',
grid: 'sideToolbar.queueProgressOverlay.viewGrid',
newest: 'sideToolbar.mediaAssets.sortNewestFirst',
oldest: 'sideToolbar.mediaAssets.sortOldestFirst',
longest: 'sideToolbar.mediaAssets.sortLongestFirst',
fastest: 'sideToolbar.mediaAssets.sortFastestFirst'
list: 'List view',
gridSmall: 'Grid (small)',
gridLarge: 'Grid (large)',
newest: 'Newest first',
oldest: 'Oldest first',
longest: 'Generation time (longest first)',
fastest: 'Generation time (fastest first)',
az: 'Name (A → Z)',
za: 'Name (Z → A)'
} as const
type ViewMode = 'list' | 'grid-small' | 'grid-large'
interface MountOptions {
viewMode?: 'list' | 'grid'
viewMode?: ViewMode
sortBy?: SortBy
showSortOptions?: boolean
showGenerationTimeSort?: boolean
}
function mountWithModels(options: MountOptions = {}) {
const viewMode = ref<'list' | 'grid'>(options.viewMode ?? 'list')
const viewMode = ref<ViewMode>(options.viewMode ?? 'list')
const sortBy = ref<SortBy>(options.sortBy ?? 'newest')
const Host = defineComponent({
@@ -48,9 +61,7 @@ function mountWithModels(options: MountOptions = {}) {
const utils = render(Host, {
global: {
mocks: {
$t: (key: string) => key
}
plugins: [i18n]
}
})
return { ...utils, viewMode, sortBy, user: userEvent.setup() }
@@ -62,17 +73,24 @@ function getButton(label: string): HTMLElement {
describe('MediaAssetSettingsMenu', () => {
describe('view-mode options (always visible)', () => {
it('renders both list and grid view options', () => {
it('renders list and both grid view options', () => {
mountWithModels()
expect(getButton(KEYS.list)).toBeTruthy()
expect(getButton(KEYS.grid)).toBeTruthy()
expect(getButton(KEYS.gridSmall)).toBeTruthy()
expect(getButton(KEYS.gridLarge)).toBeTruthy()
})
it('updates the v-model:viewMode when an option is clicked', async () => {
const { viewMode, user } = mountWithModels({ viewMode: 'list' })
await user.click(getButton(KEYS.grid))
expect(viewMode.value).toBe('grid')
})
it.for([
{ key: KEYS.gridSmall, expected: 'grid-small' },
{ key: KEYS.gridLarge, expected: 'grid-large' }
] as const)(
'updates the v-model:viewMode to $expected when an option is clicked',
async ({ key, expected }) => {
const { viewMode, user } = mountWithModels({ viewMode: 'list' })
await user.click(getButton(key))
expect(viewMode.value).toBe(expected)
}
)
})
describe('sort options (gated by showSortOptions)', () => {
@@ -112,7 +130,9 @@ describe('MediaAssetSettingsMenu', () => {
{ key: 'newest', expected: 'newest' },
{ key: 'oldest', expected: 'oldest' },
{ key: 'longest', expected: 'longest' },
{ key: 'fastest', expected: 'fastest' }
{ key: 'fastest', expected: 'fastest' },
{ key: 'az', expected: 'az' },
{ key: 'za', expected: 'za' }
]
for (const { key, expected } of cases) {

View File

@@ -18,15 +18,30 @@
<Button
variant="textonly"
class="w-full"
@click="handleViewModeChange('grid')"
@click="handleViewModeChange('grid-small')"
>
<span class="flex items-center gap-2">
<i class="icon-[lucide--layout-grid] size-4" />
<span>{{ $t('sideToolbar.queueProgressOverlay.viewGrid') }}</span>
<i class="icon-[lucide--grid-3x3] size-4" />
<span>{{ $t('sideToolbar.mediaAssets.viewGridSmall') }}</span>
</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="viewMode !== 'grid' && 'opacity-0'"
:class="viewMode !== 'grid-small' && 'opacity-0'"
/>
</Button>
<Button
variant="textonly"
class="w-full"
@click="handleViewModeChange('grid-large')"
>
<span class="flex items-center gap-2">
<i class="icon-[lucide--layout-grid] size-4" />
<span>{{ $t('sideToolbar.mediaAssets.viewGridLarge') }}</span>
</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="viewMode !== 'grid-large' && 'opacity-0'"
/>
</Button>
@@ -57,6 +72,22 @@
/>
</Button>
<Button variant="textonly" class="w-full" @click="handleSortChange('az')">
<span>{{ $t('sideToolbar.mediaAssets.sortAToZ') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="sortBy !== 'az' && 'opacity-0'"
/>
</Button>
<Button variant="textonly" class="w-full" @click="handleSortChange('za')">
<span>{{ $t('sideToolbar.mediaAssets.sortZToA') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="sortBy !== 'za' && 'opacity-0'"
/>
</Button>
<template v-if="showGenerationTimeSort">
<Button
variant="textonly"
@@ -89,7 +120,9 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
export type SortBy = 'newest' | 'oldest' | 'longest' | 'fastest'
import type { MediaAssetViewMode } from './mediaAssetViewOptions'
export type SortBy = 'newest' | 'oldest' | 'longest' | 'fastest' | 'az' | 'za'
const { showSortOptions = false, showGenerationTimeSort = false } =
defineProps<{
@@ -97,10 +130,10 @@ const { showSortOptions = false, showGenerationTimeSort = false } =
showGenerationTimeSort?: boolean
}>()
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const viewMode = defineModel<MediaAssetViewMode>('viewMode', { required: true })
const sortBy = defineModel<SortBy>('sortBy', { required: true })
function handleViewModeChange(value: 'list' | 'grid') {
function handleViewModeChange(value: MediaAssetViewMode) {
viewMode.value = value
}

View File

@@ -1,8 +1,5 @@
<template>
<p
class="m-0 line-clamp-2 text-sm/tight break-all text-base-foreground"
:title="fileName"
>
<p class="m-0 truncate text-sm/tight text-base-foreground" :title="fileName">
{{ fileName }}
</p>
</template>

View File

@@ -0,0 +1,33 @@
/**
* Single source of the media-assets filter facet values and their i18n label
* keys, shared by the filter menu (which renders the option rows) and the
* filter bar (which labels the applied-filter chips) so the two never drift.
*/
export interface FacetValue {
value: string
labelKey: string
}
export const MEDIA_TYPE_VALUES: FacetValue[] = [
{ value: 'image', labelKey: 'sideToolbar.mediaAssets.filterImage' },
{ value: 'video', labelKey: 'sideToolbar.mediaAssets.filterVideo' },
{ value: 'audio', labelKey: 'sideToolbar.mediaAssets.filterAudio' },
{ value: '3d', labelKey: 'sideToolbar.mediaAssets.filter3D' },
{ value: 'text', labelKey: 'sideToolbar.mediaAssets.filterText' }
]
export const DATE_VALUES: FacetValue[] = [
{ value: '', labelKey: 'sideToolbar.mediaAssets.dateAll' },
{ value: 'today', labelKey: 'sideToolbar.mediaAssets.dateToday' },
{ value: 'week', labelKey: 'sideToolbar.mediaAssets.datePastWeek' },
{ value: 'month', labelKey: 'sideToolbar.mediaAssets.datePastMonth' },
{ value: 'year', labelKey: 'sideToolbar.mediaAssets.dateThisYear' }
]
export function labelKeyForValue(
values: FacetValue[],
value: string
): string | undefined {
return values.find((facetValue) => facetValue.value === value)?.labelKey
}

View File

@@ -0,0 +1,8 @@
/** Sidebar view mode: a list, or one of two grid tile sizes. */
export type MediaAssetViewMode = 'list' | 'grid-small' | 'grid-large'
/** `grid-template-columns` for a grid view mode ('list' falls back to the dense grid). */
export function gridColumnsForMode(mode: MediaAssetViewMode): string {
const minWidth = mode === 'grid-large' ? 240 : 128
return `repeat(auto-fill, minmax(min(${minWidth}px, 30vw), 1fr))`
}

View File

@@ -22,7 +22,10 @@ vi.mock('@vueuse/core', async (importOriginal) => {
}
})
import { useAssetSelection } from './useAssetSelection'
import {
shouldInterceptSelectAll,
useAssetSelection
} from './useAssetSelection'
import { useAssetSelectionStore } from './useAssetSelectionStore'
function createMockAssets(count: number): AssetItem[] {
@@ -132,6 +135,51 @@ describe('useAssetSelection', () => {
expect(isSelected('asset-1')).toBe(true)
expect(selectedCount.value).toBe(1)
})
it('deselects when clicking the only selected asset again', () => {
const { handleAssetClick, isSelected, selectedCount } =
useAssetSelection()
const assets = createMockAssets(3)
handleAssetClick(assets[0], 0, assets)
expect(selectedCount.value).toBe(1)
handleAssetClick(assets[0], 0, assets)
expect(isSelected('asset-0')).toBe(false)
expect(selectedCount.value).toBe(0)
})
it('starts a new selection after deselecting the anchor', () => {
const { handleAssetClick, isSelected, selectedCount } =
useAssetSelection()
const assets = createMockAssets(3)
handleAssetClick(assets[0], 0, assets)
handleAssetClick(assets[0], 0, assets)
mockShiftKey.value = true
handleAssetClick(assets[2], 2, assets)
expect(isSelected('asset-0')).toBe(false)
expect(isSelected('asset-2')).toBe(true)
expect(selectedCount.value).toBe(1)
})
it('collapses a multi-selection to the clicked asset rather than deselecting', () => {
const { handleAssetClick, isSelected, selectedCount } =
useAssetSelection()
const assets = createMockAssets(3)
handleAssetClick(assets[0], 0, assets)
mockCtrlKey.value = true
handleAssetClick(assets[1], 1, assets)
mockCtrlKey.value = false
expect(selectedCount.value).toBe(2)
handleAssetClick(assets[0], 0, assets)
expect(isSelected('asset-0')).toBe(true)
expect(isSelected('asset-1')).toBe(false)
expect(selectedCount.value).toBe(1)
})
})
describe('handleAssetClick - shift+click', () => {
@@ -273,3 +321,61 @@ describe('useAssetSelection', () => {
})
})
})
describe('shouldInterceptSelectAll', () => {
function interceptsOn(
focusedTag: string,
init: KeyboardEventInit = { key: 'a', metaKey: true },
{ insidePane = true, contentEditable = false } = {}
): boolean {
const pane = document.createElement('div')
document.body.append(pane)
const focused = document.createElement(focusedTag)
if (contentEditable) {
Object.defineProperty(focused, 'isContentEditable', { value: true })
}
;(insidePane ? pane : document.body).append(focused)
let result = false
const record = (event: Event) => {
result = shouldInterceptSelectAll(event as KeyboardEvent, pane)
}
window.addEventListener('keydown', record, { capture: true })
focused.dispatchEvent(
new KeyboardEvent('keydown', { ...init, bubbles: true })
)
window.removeEventListener('keydown', record, { capture: true })
pane.remove()
focused.remove()
return result
}
it('intercepts Cmd+A and Ctrl+A on non-text elements inside the pane', () => {
expect(interceptsOn('div', { key: 'a', metaKey: true })).toBe(true)
expect(interceptsOn('div', { key: 'A', ctrlKey: true })).toBe(true)
})
it('requires the select-all chord', () => {
expect(interceptsOn('div', { key: 'a' })).toBe(false)
expect(interceptsOn('div', { key: 'b', metaKey: true })).toBe(false)
})
it('leaves native select-all to text-entry elements', () => {
for (const tag of ['input', 'textarea', 'select']) {
expect(interceptsOn(tag, { key: 'a', metaKey: true })).toBe(false)
}
expect(
interceptsOn(
'div',
{ key: 'a', metaKey: true },
{ contentEditable: true }
)
).toBe(false)
})
it('ignores the chord when focus is outside the pane', () => {
expect(
interceptsOn('div', { key: 'a', metaKey: true }, { insidePane: false })
).toBe(false)
})
})

View File

@@ -8,6 +8,25 @@ import {
getTotalAssetOutputCount
} from '@/platform/assets/utils/outputAssetUtil'
/**
* True when a Cmd/Ctrl+A keydown should become "select all assets": the chord
* matches, focus is inside the asset pane, and the target is not a text-entry
* element (those keep native select-all).
*/
export function shouldInterceptSelectAll(
event: KeyboardEvent,
pane: HTMLElement | undefined
): boolean {
if (event.key !== 'a' && event.key !== 'A') return false
if (!event.metaKey && !event.ctrlKey) return false
const target = event.target
if (!(target instanceof HTMLElement) || !pane?.contains(target)) return false
return !(
target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
)
}
export function useAssetSelection() {
const selectionStore = useAssetSelectionStore()
@@ -83,7 +102,16 @@ export function useAssetSelection() {
return
}
// Normal Click: Single selection
// Normal Click: deselect when it is already the sole selection, otherwise
// collapse to a single selection.
if (
selectionStore.isSelected(assetId) &&
selectionStore.selectedCount === 1
) {
selectionStore.clearSelection()
setAnchor(-1, null)
return
}
selectionStore.clearSelection()
selectionStore.addToSelection(assetId)
setAnchor(index, assetId)

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -169,6 +169,92 @@ describe('useMediaAssetFiltering', () => {
})
})
describe('alphabetical sort', () => {
it('sorts A→Z by display name (case-insensitive)', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'b', name: 'banana.png' }),
makeAsset({ id: 'a', name: 'Apple.png' }),
makeAsset({ id: 'c', name: 'cherry.png' })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'az'
expect(ids(filteredAssets.value)).toEqual(['a', 'b', 'c'])
})
it('sorts Z→A by display name (case-insensitive)', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'b', name: 'banana.png' }),
makeAsset({ id: 'a', name: 'Apple.png' }),
makeAsset({ id: 'c', name: 'cherry.png' })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'za'
expect(ids(filteredAssets.value)).toEqual(['c', 'b', 'a'])
})
})
describe('date filter', () => {
afterEach(() => {
vi.useRealTimers()
})
function datedAssets() {
const now = Date.now()
return ref<AssetItem[]>([
makeAsset({ id: 'recent', name: 'a.png', createTime: now }),
makeAsset({
id: 'stale',
name: 'b.png',
createTime: now - 40 * 86_400_000
})
])
}
it('returns every asset when no date preset is set', () => {
const { filteredAssets } = useMediaAssetFiltering(datedAssets())
expect(ids(filteredAssets.value).sort()).toEqual(['recent', 'stale'])
})
it('keeps only assets inside the selected preset window', () => {
const { dateFilter, filteredAssets } =
useMediaAssetFiltering(datedAssets())
dateFilter.value = 'month'
expect(ids(filteredAssets.value)).toEqual(['recent'])
})
it('today cuts off at local midnight', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 5, 15, 12, 0, 0))
const midnight = new Date(2026, 5, 15, 0, 0, 0).getTime()
const assets = ref<AssetItem[]>([
makeAsset({ id: 'this-morning', name: 'a.png', createTime: midnight }),
makeAsset({ id: 'yesterday', name: 'b.png', createTime: midnight - 1 })
])
const { dateFilter, filteredAssets } = useMediaAssetFiltering(assets)
dateFilter.value = 'today'
expect(ids(filteredAssets.value)).toEqual(['this-morning'])
})
it('year cuts off at January 1', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 5, 15))
const janFirst = new Date(2026, 0, 1).getTime()
const assets = ref<AssetItem[]>([
makeAsset({ id: 'this-year', name: 'a.png', createTime: janFirst }),
makeAsset({ id: 'last-year', name: 'b.png', createTime: janFirst - 1 })
])
const { dateFilter, filteredAssets } = useMediaAssetFiltering(assets)
dateFilter.value = 'year'
expect(ids(filteredAssets.value)).toEqual(['this-year'])
})
})
describe('composition', () => {
it('applies media-type filter then sort', () => {
const t1 = 1_000_000

View File

@@ -7,7 +7,7 @@ import type { Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
type SortOption = 'newest' | 'oldest' | 'longest' | 'fastest'
type SortOption = 'newest' | 'oldest' | 'longest' | 'fastest' | 'az' | 'za'
/**
* Get timestamp from asset (either create_time or created_at)
@@ -26,6 +26,30 @@ const getAssetExecutionTime = (asset: AssetItem): number => {
return (asset.user_metadata?.executionTimeInSeconds as number) ?? 0
}
/** Case-insensitive display name used for alphabetical sorting. */
const getAssetSortName = (asset: AssetItem): string => {
return (asset.display_name || asset.name || '').toLowerCase()
}
/** Inclusive lower bound (ms) for a date preset. */
const datePresetStart = (preset: string): number => {
const now = Date.now()
const start = new Date(now)
switch (preset) {
case 'today':
start.setHours(0, 0, 0, 0)
return start.getTime()
case 'week':
return now - 7 * 86_400_000
case 'month':
return now - 30 * 86_400_000
case 'year':
return new Date(start.getFullYear(), 0, 1).getTime()
default:
return 0
}
}
/**
* Media Asset Filtering composable
* Manages search, filter, and sort for media assets
@@ -35,6 +59,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const sortBy = ref<SortOption>('newest')
const mediaTypeFilters = ref<string[]>([])
const dateFilter = ref('')
const fuseOptions = {
keys: ['display_name', 'name'],
@@ -67,24 +92,40 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
})
})
const dateFiltered = computed(() => {
if (!dateFilter.value) {
return typeFiltered.value
}
const start = datePresetStart(dateFilter.value)
return typeFiltered.value.filter((asset) => getAssetTime(asset) >= start)
})
const filteredAssets = computed(() => {
// Sort by create_time (output assets) or created_at (input assets)
switch (sortBy.value) {
case 'oldest':
// Ascending order (oldest first)
return sortByUtil(typeFiltered.value, [getAssetTime])
return sortByUtil(dateFiltered.value, [getAssetTime])
case 'longest':
// Descending order (longest execution time first)
return sortByUtil(typeFiltered.value, [
return sortByUtil(dateFiltered.value, [
(asset) => -getAssetExecutionTime(asset)
])
case 'fastest':
// Ascending order (fastest execution time first)
return sortByUtil(typeFiltered.value, [getAssetExecutionTime])
return sortByUtil(dateFiltered.value, [getAssetExecutionTime])
case 'az':
return [...dateFiltered.value].sort((a, b) =>
getAssetSortName(a).localeCompare(getAssetSortName(b))
)
case 'za':
return [...dateFiltered.value].sort((a, b) =>
getAssetSortName(b).localeCompare(getAssetSortName(a))
)
case 'newest':
default:
// Descending order (newest first) - negate for descending
return sortByUtil(typeFiltered.value, [(asset) => -getAssetTime(asset)])
return sortByUtil(dateFiltered.value, [(asset) => -getAssetTime(asset)])
}
})
@@ -92,6 +133,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
searchQuery,
sortBy,
mediaTypeFilters,
dateFilter,
filteredAssets
}
}

View File

@@ -1,12 +1,5 @@
<template>
<div
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
>
<h1
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
>
{{ $t('cloudOnboarding.survey.title') }}
</h1>
<div class="flex h-[700px] max-h-[85vh] w-[320px] max-w-[90vw] flex-col">
<DynamicSurveyForm
:key="activeSurvey.version"
:survey="activeSurvey"

View File

@@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import CloudTemplate from './CloudTemplate.vue'
const renderWithMeta = async (meta: Record<string, unknown>) => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', name: 'test', component: CloudTemplate, meta }]
})
await router.push('/')
await router.isReady()
return render(CloudTemplate, {
global: {
plugins: [router],
stubs: {
CloudHeroCarousel: { template: '<div data-testid="hero" />' },
CloudTemplateFooter: true
}
}
})
}
describe('CloudTemplate', () => {
it('shows the hero carousel when the route does not hide it', async () => {
await renderWithMeta({})
expect(screen.getByTestId('hero')).toBeInTheDocument()
})
it('hides the hero carousel when route.meta.hideHero is set', async () => {
await renderWithMeta({ hideHero: true })
expect(screen.queryByTestId('hero')).not.toBeInTheDocument()
})
})

View File

@@ -13,22 +13,15 @@
</div>
<CloudTemplateFooter />
</div>
<div
v-if="!route.meta.hideHero"
class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block"
>
<div class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block">
<CloudHeroCarousel />
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
const route = useRoute()
</script>
<style>
@import '../assets/css/fonts.css';

View File

@@ -5,22 +5,20 @@
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
class="cursor-pointer text-sm text-gray-600 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
class="cursor-pointer text-sm text-gray-600 no-underline"
>
{{ t('auth.login.privacyLink') }}
</a>
<a
href="https://support.comfy.org"
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
class="cursor-pointer text-sm text-gray-600 no-underline"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -94,7 +94,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
name: 'cloud-survey',
component: () =>
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
meta: { requiresAuth: true, hideHero: true }
meta: { requiresAuth: true }
},
{
path: 'oauth/consent',
@@ -106,7 +106,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
name: 'cloud-user-check',
component: () =>
import('@/platform/cloud/onboarding/UserCheckView.vue'),
meta: { requiresAuth: true, hideHero: true }
meta: { requiresAuth: true }
},
{
path: 'sorry-contact-support',

View File

@@ -1,176 +0,0 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { OnboardingSurveyField } from '@/platform/remoteConfig/types'
import DynamicSurveyField from './DynamicSurveyField.vue'
const renderField = (
field: OnboardingSurveyField,
props: {
modelValue?: string | string[]
otherValue?: string
errorMessage?: string
} = {},
locale = 'en'
) =>
render(DynamicSurveyField, {
global: {
plugins: [
createI18n({ legacy: false, locale, messages: { en: enMessages } })
]
},
props: { field, modelValue: undefined, ...props }
})
const optionButton = (label: string) =>
screen.getByRole('button', { name: label })
describe('DynamicSurveyField', () => {
const singleField: OnboardingSurveyField = {
id: 'intent',
type: 'single',
label: 'What do you want to make?',
required: true,
options: [
{ value: 'images', label: 'Images', icon: 'icon-[lucide--image]' },
{ value: 'video', label: 'Video' }
]
}
it('renders the label and one card per option', () => {
renderField(singleField)
expect(screen.getByText('What do you want to make?')).toBeVisible()
expect(screen.getByText('Images')).toBeInTheDocument()
expect(screen.getByText('Video')).toBeInTheDocument()
})
it('emits the chosen value for a single-select card', async () => {
const user = userEvent.setup()
const { emitted } = renderField(singleField)
await user.click(screen.getByText('Images'))
expect(emitted()['update:modelValue']?.[0]).toEqual(['images'])
})
it('marks the selected single card as on (aria-pressed/state)', () => {
renderField(singleField, { modelValue: 'images' })
expect(optionButton('Images')).toHaveAttribute('data-state', 'on')
expect(optionButton('Video')).toHaveAttribute('data-state', 'off')
})
it('gives each option card a stable "<fieldId>-<value>" id', () => {
renderField(singleField)
expect(optionButton('Images')).toHaveAttribute('id', 'intent-images')
expect(optionButton('Video')).toHaveAttribute('id', 'intent-video')
})
const multiField: OnboardingSurveyField = {
id: 'making',
type: 'multi',
label: 'Pick some',
required: true,
options: [
{ value: 'a', label: 'Making A' },
{ value: 'b', label: 'Making B' }
]
}
it('emits an array for a multi-select card and reflects current selection', async () => {
const user = userEvent.setup()
const { emitted } = renderField(multiField, { modelValue: ['a'] })
expect(optionButton('Making A')).toHaveAttribute('data-state', 'on')
await user.click(screen.getByText('Making B'))
const events = emitted()['update:modelValue'] as unknown[][] | undefined
const last = events?.at(-1)?.[0]
expect(last).toEqual(expect.arrayContaining(['a', 'b']))
})
it('shows the "other" free-text input only when "other" is selected and emits it', async () => {
const user = userEvent.setup()
const otherField: OnboardingSurveyField = {
id: 'source',
type: 'single',
label: 'How did you find us?',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', label: 'Web search' },
{ value: 'other', label: 'Somewhere else' }
]
}
const { rerender, emitted } = renderField(otherField, {
modelValue: 'search'
})
expect(
screen.queryByPlaceholderText('Where did you find us?')
).not.toBeInTheDocument()
await rerender({ field: otherField, modelValue: 'other', otherValue: '' })
const input = screen.getByPlaceholderText('Where did you find us?')
await user.type(input, 'A podcast')
expect(emitted()['update:otherValue']?.length).toBeGreaterThan(0)
})
it('renders a text field and emits typed input', async () => {
const user = userEvent.setup()
const textField: OnboardingSurveyField = {
id: 'note',
type: 'text',
label: 'Anything else?',
placeholder: 'Your note'
}
const { emitted } = renderField(textField)
await user.type(screen.getByPlaceholderText('Your note'), 'Hi')
expect(emitted()['update:modelValue']?.length).toBeGreaterThan(0)
})
it('resolves labels via labelKey, locale map, and falls back to the value', () => {
renderField(
{
id: 'q',
type: 'single',
labelKey: 'cloudSurvey_steps_intent',
options: [
{ value: 'x', label: { en: 'Ex', ko: '엑스' } },
{ value: 'raw' } // no label → falls back to the value
]
},
{}
)
expect(screen.getByText('What do you want to make?')).toBeVisible()
expect(screen.getByText('Ex')).toBeInTheDocument()
expect(screen.getByText('raw')).toBeInTheDocument()
})
it('resolves a field label from a locale map when no labelKey is set', () => {
renderField({
id: 'q',
type: 'single',
label: { en: 'Server question', ko: '서버 질문' },
options: [{ value: 'a', label: 'A' }]
})
expect(screen.getByText('Server question')).toBeVisible()
})
it('falls back to the field id when neither labelKey nor label resolves', () => {
renderField({
id: 'bare_field_id',
type: 'single',
options: [{ value: 'a', label: 'A' }]
})
expect(screen.getByText('bare_field_id')).toBeVisible()
})
it('renders the error message when provided', () => {
renderField(singleField, { errorMessage: 'Please choose an option.' })
expect(screen.getByText('Please choose an option.')).toBeVisible()
})
})

View File

@@ -2,72 +2,62 @@
<fieldset
v-if="field.type !== 'text'"
:aria-invalid="Boolean(errorMessage)"
class="m-0 flex flex-col gap-4 border-0 p-0"
class="flex flex-col gap-4 border-0 p-0"
>
<legend class="mb-2 block text-lg font-medium text-primary-comfy-canvas">
<legend class="mb-2 block text-lg font-medium text-base-foreground">
{{ resolvedLabel }}
</legend>
<ToggleGroup
v-if="field.type === 'single'"
:model-value="(modelValue as string) ?? ''"
type="single"
class="flex w-full flex-col gap-2"
@update:model-value="onSingleChange"
>
<ToggleGroupItem
<template v-if="field.type === 'single'">
<div
v-for="option in field.options"
:id="`${field.id}-${option.value}`"
:key="option.value"
:value="option.value"
:class="optionCardClass"
class="flex items-center gap-3"
>
<i
v-if="option.icon"
:class="
cn('size-4 shrink-0 text-primary-comfy-canvas/60', option.icon)
"
aria-hidden="true"
<RadioButton
:model-value="(modelValue as string) ?? ''"
:input-id="`${field.id}-${option.value}`"
:name="field.id"
:value="option.value"
:dt="checkedTokens"
@update:model-value="onSingleChange"
/>
<span class="flex-1">{{ resolveOptionLabel(option) }}</span>
<i :class="checkMarkClass" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
v-else
:model-value="(modelValue as string[]) ?? []"
type="multiple"
class="flex w-full flex-col gap-2"
@update:model-value="onMultiChange"
>
<ToggleGroupItem
<label
:for="`${field.id}-${option.value}`"
class="cursor-pointer text-sm"
>{{ resolveOptionLabel(option) }}</label
>
</div>
</template>
<template v-else>
<div
v-for="option in field.options"
:id="`${field.id}-${option.value}`"
:key="option.value"
:value="option.value"
:class="optionCardClass"
class="flex items-center gap-3"
>
<i
v-if="option.icon"
:class="
cn('size-4 shrink-0 text-primary-comfy-canvas/60', option.icon)
"
aria-hidden="true"
<Checkbox
:model-value="(modelValue as string[]) ?? []"
:input-id="`${field.id}-${option.value}`"
:value="option.value"
:dt="checkedTokens"
@update:model-value="onMultiChange"
/>
<span class="flex-1">{{ resolveOptionLabel(option) }}</span>
<i :class="checkMarkClass" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
<label
:for="`${field.id}-${option.value}`"
class="cursor-pointer text-sm"
>{{ resolveOptionLabel(option) }}</label
>
</div>
</template>
<Input
v-if="field.allowOther && field.otherFieldId && isOtherSelected"
v-if="field.allowOther && field.otherFieldId && modelValue === 'other'"
:model-value="(otherValue as string) ?? ''"
:class="inputClass"
:maxlength="OTHER_TEXT_MAX_LENGTH"
:placeholder="
$t(
`cloudOnboarding.survey.options.${field.id}.otherPlaceholder`,
$t('cloudOnboarding.survey.otherPlaceholder')
)
"
class="ml-1"
@update:model-value="onOtherChange"
/>
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
@@ -75,7 +65,7 @@
<div v-else class="flex flex-col gap-3">
<label
:for="controlId"
class="block text-lg font-medium text-primary-comfy-canvas"
class="block text-lg font-medium text-base-foreground"
>
{{ resolvedLabel }}
</label>
@@ -84,7 +74,6 @@
:model-value="(modelValue as string) ?? ''"
:placeholder="field.placeholder"
:aria-invalid="Boolean(errorMessage)"
:class="inputClass"
@update:model-value="onTextChange"
/>
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
@@ -92,20 +81,18 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, useId } from 'vue'
import Checkbox from 'primevue/checkbox'
import RadioButton from 'primevue/radiobutton'
import { useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Input from '@/components/ui/input/Input.vue'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type {
LocalizedString,
OnboardingSurveyField,
OnboardingSurveyOption
} from '@/platform/remoteConfig/types'
import { OTHER_TEXT_MAX_LENGTH } from './surveySchema'
const {
field,
modelValue,
@@ -126,31 +113,25 @@ const emit = defineEmits<{
const { t, te, locale } = useI18n()
const controlId = useId()
const optionCardClass =
'group h-auto w-full items-center justify-start gap-3 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 px-4 py-3 text-left text-sm text-primary-comfy-canvas shadow-inset-highlight transition-colors hover:bg-sand-300/20 data-[state=on]:bg-sand-300/15 data-[state=on]:ring-1 data-[state=on]:ring-inset data-[state=on]:ring-brand-yellow'
const checkMarkClass =
'icon-[lucide--check] size-4 shrink-0 text-brand-yellow opacity-0 group-data-[state=on]:opacity-100'
const inputClass =
'border-smoke-800/10 bg-smoke-800/10 text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/50 focus-visible:ring-inset'
const isOtherSelected = computed(() =>
Array.isArray(modelValue)
? modelValue.includes('other')
: modelValue === 'other'
)
const resolveLocalized = (value: LocalizedString): string => {
if (typeof value === 'string') return value
return value[locale.value] ?? value.en ?? Object.values(value)[0] ?? ''
}
const resolvedLabel = computed(() => {
const checkedTokens = {
checked: {
background: 'var(--color-electric-400)',
borderColor: 'var(--color-electric-400)',
hoverBackground: 'var(--color-electric-400)',
hoverBorderColor: 'var(--color-electric-400)'
}
}
const resolvedLabel = (() => {
if (field.labelKey && te(field.labelKey)) return t(field.labelKey)
if (field.label != null) return resolveLocalized(field.label)
return field.id
})
})()
const resolveOptionLabel = (option: OnboardingSurveyOption): string => {
if (option.labelKey && te(option.labelKey)) return t(option.labelKey)
@@ -162,10 +143,13 @@ const onSingleChange = (value: unknown) => {
emit('update:modelValue', typeof value === 'string' ? value : '')
}
const onMultiChange = (value: unknown) => {
const selected = Array.isArray(value) ? value : []
if (!Array.isArray(value)) {
emit('update:modelValue', [])
return
}
emit(
'update:modelValue',
selected.filter((v): v is string => typeof v === 'string')
value.filter((v): v is string => typeof v === 'string')
)
}
const onTextChange = (value: string | number | undefined) => {

View File

@@ -1,374 +1,154 @@
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import DynamicSurveyForm from './DynamicSurveyForm.vue'
import { defaultOnboardingSurvey } from './defaultSurveySchema'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: 'Help us tailor your ComfyUI experience.',
errors: {
chooseAnOption: 'Please choose an option.',
selectAtLeastOne: 'Please select at least one option.',
describeAnswer: 'Please describe your answer.'
}
}
}
}
}
})
const renderForm = (survey: OnboardingSurvey) =>
render(DynamicSurveyForm, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
},
global: { plugins: [PrimeVue, i18n] },
props: { survey }
})
const clickOption = (user: ReturnType<typeof userEvent.setup>, label: string) =>
user.click(screen.getByText(label))
const firstSubmitPayload = (
emitted: Record<string, unknown[]>
): Record<string, unknown> | undefined =>
(emitted.submit?.[0] as [Record<string, unknown>] | undefined)?.[0]
const twoStepSurvey: OnboardingSurvey = {
version: 1,
introKey: 'cloudOnboarding.survey.intro',
fields: [
{
id: 'intent',
id: 'usage',
type: 'single',
label: 'What do you want to make?',
label: 'How do you plan to use ComfyUI?',
required: true,
options: [
{ value: 'personal', label: 'Personal use' },
{ value: 'work', label: 'Work' }
]
},
{
id: 'intent',
type: 'multi',
label: 'What do you want to create with ComfyUI?',
required: true,
options: [
{ value: 'images', label: 'Images' },
{ value: 'video', label: 'Video' }
{ value: 'videos', label: 'Videos' }
]
},
{
id: 'making',
type: 'multi',
label: 'Pick everything that applies',
required: true,
options: [
{ value: 'a', label: 'Making A' },
{ value: 'b', label: 'Making B' }
]
}
]
}
const branchedSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'intent',
type: 'single',
label: 'What do you want to make?',
required: true,
options: [
{ value: 'workflows', label: 'Workflows' },
{ value: 'images', label: 'Images' }
]
},
{
id: 'focus',
type: 'single',
label: 'What are you building?',
required: true,
showWhen: { field: 'intent', equals: 'workflows' },
options: [{ value: 'custom_nodes', label: 'Custom nodes' }]
}
]
}
describe('DynamicSurveyForm', () => {
it('renders the real default schema (v3) with its first question and options', () => {
expect(defaultOnboardingSurvey.version).toBe(3)
const firstField = defaultOnboardingSurvey.fields[0]!
renderForm(defaultOnboardingSurvey)
expect(screen.getByText('What do you want to make?')).toBeVisible()
expect(screen.getByText('Images')).toBeInTheDocument()
expect(screen.getAllByRole('button')).toHaveLength(
firstField.options!.length
)
})
it('auto-advances when a single-select option is chosen', async () => {
const user = userEvent.setup()
it('renders the intro text and the first field options', () => {
renderForm(twoStepSurvey)
// No Next click — choosing the card advances the wizard.
await clickOption(user, 'Images')
expect(
await screen.findByText('Pick everything that applies')
).toBeVisible()
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
screen.getByText('Help us tailor your ComfyUI experience.')
).toBeInTheDocument()
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
expect(screen.getByLabelText('Personal use')).toBeInTheDocument()
expect(screen.getByLabelText('Work')).toBeInTheDocument()
})
it('does not auto-advance a multi-select step; Submit gates on a choice', async () => {
it('disables Next until the user selects an option, then advances', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await clickOption(user, 'Images')
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeDisabled()
const submit = await screen.findByRole('button', { name: 'Submit' })
expect(submit).toBeDisabled()
await user.click(screen.getByLabelText('Personal use'))
expect(next).toBeEnabled()
await clickOption(user, 'Making A')
// Still on the multi step (no auto-advance), now submittable.
expect(screen.getByText('Pick everything that applies')).toBeVisible()
await waitFor(() => expect(submit).toBeEnabled())
await user.click(next)
await flushPromises()
expect(
screen.getByText('What do you want to create with ComfyUI?')
).toBeVisible()
expect(screen.getByLabelText('Images')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
})
it('navigates back to the previous step', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await clickOption(user, 'Images')
await user.click(screen.getByLabelText('Personal use'))
await user.click(screen.getByRole('button', { name: 'Next' }))
await flushPromises()
expect(
await screen.findByText('Pick everything that applies')
screen.getByText('What do you want to create with ComfyUI?')
).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(await screen.findByText('What do you want to make?')).toBeVisible()
await flushPromises()
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
})
it('offers Next on an already-answered single-select reached via Back', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await clickOption(user, 'Images')
await screen.findByText('Pick everything that applies')
await user.click(screen.getByRole('button', { name: 'Back' }))
const next = await screen.findByRole('button', { name: 'Next' })
await user.click(next)
expect(
await screen.findByText('Pick everything that applies')
).toBeVisible()
})
it('reveals a branched follow-up step from the answer and submits it', async () => {
const user = userEvent.setup()
const { emitted } = renderForm(branchedSurvey)
await clickOption(user, 'Workflows')
expect(await screen.findByText('What are you building?')).toBeVisible()
await clickOption(user, 'Custom nodes')
await user.click(await screen.findByRole('button', { name: 'Submit' }))
await waitFor(() =>
expect(firstSubmitPayload(emitted())).toEqual({
intent: 'workflows',
focus: 'custom_nodes'
})
)
})
it('hides the branched step when the answer does not match', async () => {
const user = userEvent.setup()
const { emitted } = renderForm(branchedSurvey)
// 'images' is the last visible step (focus hidden) → Submit, no branch.
await clickOption(user, 'Images')
const submit = await screen.findByRole('button', { name: 'Submit' })
expect(screen.queryByText('What are you building?')).not.toBeInTheDocument()
await user.click(submit)
await waitFor(() =>
expect(firstSubmitPayload(emitted())).toEqual({
intent: 'images',
focus: ''
})
)
})
it('requires the "other" free-text before submitting, then submits it', async () => {
const user = userEvent.setup()
const otherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'source',
type: 'single',
label: 'How did you find us?',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', label: 'Web search' },
{ value: 'other', label: 'Somewhere else' }
]
it('resolves option and field labels via labelKey when provided', () => {
const localizedI18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: 'Help us tailor your ComfyUI experience.',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
},
survey_label: 'Localized question?',
survey_a: 'Localized A',
survey_b: 'Localized B'
}
]
}
const { emitted } = renderForm(otherSurvey)
// Selecting 'other' must NOT auto-advance — the text box is required.
await clickOption(user, 'Somewhere else')
const submit = await screen.findByRole('button', { name: 'Submit' })
expect(submit).toBeDisabled()
await user.type(
await screen.findByPlaceholderText('Where did you find us?'),
'A newsletter'
)
await waitFor(() => expect(submit).toBeEnabled())
await user.click(submit)
await waitFor(() =>
expect(firstSubmitPayload(emitted())).toEqual({ source: 'A newsletter' })
)
})
it('surfaces the free-text error once "other" text is touched then cleared', async () => {
const user = userEvent.setup()
const otherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'source',
type: 'single',
label: 'How did you find us?',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', label: 'Web search' },
{ value: 'other', label: 'Somewhere else' }
]
}
]
}
renderForm(otherSurvey)
await clickOption(user, 'Somewhere else')
const input = await screen.findByPlaceholderText('Where did you find us?')
// Type then clear → the free-text field is touched but empty, so its
// required error surfaces.
await user.type(input, 'x')
await user.clear(input)
expect(
await screen.findByText('Please describe your answer.')
).toBeVisible()
})
it('shows a required-field error only after the user interacts, not before', async () => {
const user = userEvent.setup()
renderForm({
version: 1,
fields: [
{
id: 'making',
type: 'multi',
label: 'Pick everything that applies',
required: true,
options: [{ value: 'a', label: 'Making A' }]
}
]
}
})
// No error on first render (field untouched).
expect(
screen.queryByText('Please select at least one option.')
).not.toBeInTheDocument()
// Select then clear → field is touched but empty → error surfaces.
await user.click(screen.getByText('Making A'))
await user.click(screen.getByText('Making A'))
expect(
await screen.findByText('Please select at least one option.')
).toBeVisible()
})
it('allows advancing past an optional field while still empty', async () => {
const user = userEvent.setup()
renderForm({
version: 1,
fields: [
{
id: 'q1',
type: 'single',
label: 'Optional question?',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' }
]
// no required: true — should be skippable
},
{
id: 'q2',
type: 'single',
label: 'Required question?',
required: true,
options: [{ value: 'c', label: 'C' }]
}
]
})
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeEnabled()
await user.click(next)
expect(await screen.findByText('Required question?')).toBeVisible()
})
it('resets to the first step when the survey prop changes', async () => {
const user = userEvent.setup()
const { rerender } = render(DynamicSurveyForm, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
},
props: { survey: twoStepSurvey }
})
await clickOption(user, 'Images')
expect(
await screen.findByText('Pick everything that applies')
).toBeVisible()
await rerender({ survey: branchedSurvey })
// Back on step 0 of the new survey (no Back button on the first step).
expect(await screen.findByText('What do you want to make?')).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Back' })
).not.toBeInTheDocument()
})
it('renders server-supplied label translations and falls back to English', () => {
render(DynamicSurveyForm, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'ko',
fallbackLocale: 'en',
messages: { en: enMessages, ko: { g: { next: '다음' } } }
})
]
},
global: { plugins: [PrimeVue, localizedI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'intent',
id: 'q',
type: 'single',
label: { en: 'What will you make?', ko: '무엇을 만들 건가요?' },
labelKey: 'survey_label',
required: true,
options: [
// ko provided → localized; ko missing → English fallback
{ value: 'images', label: { en: 'Images', ko: '이미지' } },
{ value: 'video', label: { en: 'Video' } }
{ value: 'a', labelKey: 'survey_a' },
{ value: 'b', labelKey: 'survey_b' }
]
}
]
@@ -376,8 +156,165 @@ describe('DynamicSurveyForm', () => {
}
})
expect(screen.getByText('무엇을 만들 건가요?')).toBeVisible()
expect(screen.getByText('이미지')).toBeInTheDocument()
expect(screen.getByText('Video')).toBeInTheDocument()
expect(screen.getByText('Localized question?')).toBeVisible()
expect(screen.getByLabelText('Localized A')).toBeInTheDocument()
expect(screen.getByLabelText('Localized B')).toBeInTheDocument()
})
it('renders server-supplied translations from a label locale map', () => {
const koreanI18n = createI18n({
legacy: false,
locale: 'ko',
fallbackLocale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: '',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
}
},
ko: { g: { back: '뒤로', next: '다음', submit: '제출' } }
}
})
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, koreanI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'usage',
type: 'single',
label: {
en: 'How will you use it?',
ko: '어떻게 사용하시겠어요?'
},
required: true,
options: [
{
value: 'personal',
label: { en: 'Personal use', ko: '개인 용도' }
},
{ value: 'work', label: { en: 'Work', ko: '업무' } }
]
}
]
}
}
})
expect(screen.getByText('어떻게 사용하시겠어요?')).toBeVisible()
expect(screen.getByLabelText('개인 용도')).toBeInTheDocument()
expect(screen.getByLabelText('업무')).toBeInTheDocument()
})
it('falls back to English when current locale missing from label map', () => {
const fallbackI18n = createI18n({
legacy: false,
locale: 'fr',
fallbackLocale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: '',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
}
},
fr: {}
}
})
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, fallbackI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q',
type: 'single',
label: { en: 'English question', ko: '한국어' },
required: true,
options: [
{ value: 'a', label: { en: 'English A', ko: '한국어 A' } }
]
}
]
}
}
})
// fr is not in the map → falls back to en
expect(screen.getByText('English question')).toBeVisible()
expect(screen.getByLabelText('English A')).toBeInTheDocument()
})
it('allows advancing past an optional field while still empty', async () => {
const user = userEvent.setup()
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, i18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q1',
type: 'single',
label: 'Optional question?',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' }
]
// no required: true — should be skippable
},
{
id: 'q2',
type: 'single',
label: 'Required question?',
required: true,
options: [{ value: 'c', label: 'C' }]
}
]
}
}
})
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeEnabled()
await user.click(next)
await flushPromises()
expect(screen.getByText('Required question?')).toBeVisible()
})
it('enables Submit only after the multi-select field has at least one choice', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await user.click(screen.getByLabelText('Work'))
await user.click(screen.getByRole('button', { name: 'Next' }))
await flushPromises()
const submitBtn = screen.getByRole('button', { name: 'Submit' })
expect(submitBtn).toBeDisabled()
await user.click(screen.getByRole('checkbox', { name: /Images/i }))
await flushPromises()
expect(submitBtn).toBeEnabled()
})
})

View File

@@ -1,118 +1,109 @@
<template>
<form class="flex w-full flex-col" @submit.prevent="onSubmit">
<p v-if="introText" class="mb-4 text-sm text-muted-foreground">
<form class="flex size-full flex-col" @submit.prevent="onSubmit">
<p v-if="introText" class="mb-4 text-sm text-muted">
{{ introText }}
</p>
<div
class="mb-8 h-1.5 w-full overflow-hidden rounded-full bg-primary-comfy-canvas/10"
class="mb-8 h-2 w-full overflow-hidden rounded-full bg-secondary-background"
>
<div
class="h-full bg-brand-yellow transition-[width] duration-300 ease-out"
class="h-full bg-electric-400 transition-[width] duration-300 ease-out"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<div
class="overflow-hidden transition-[height] duration-300 ease-out"
:style="animatedHeightStyle"
>
<div ref="questionContent" class="relative">
<Transition
enter-active-class="transition-opacity duration-300 ease-out"
enter-from-class="opacity-0"
leave-active-class="absolute inset-x-0 top-0 transition-opacity duration-300 ease-out"
leave-to-class="opacity-0"
>
<div
v-if="currentField"
:key="currentField.id"
class="flex flex-col gap-4"
>
<DynamicSurveyField
:field="currentField"
:model-value="values[currentField.id]"
:other-value="
currentField.otherFieldId
? (values[currentField.otherFieldId] as string)
: undefined
"
:error-message="currentError"
@update:model-value="
(value) => void onFieldChange(currentField.id, value)
"
@update:other-value="
(value) =>
currentField.otherFieldId &&
void onFieldChange(currentField.otherFieldId, value)
"
/>
</div>
</Transition>
<div class="flex flex-1 flex-col overflow-hidden">
<div
v-if="currentField"
:key="currentField.id"
class="flex flex-1 flex-col gap-4 overflow-y-auto pr-1"
>
<DynamicSurveyField
:field="currentField"
:model-value="values[currentField.id]"
:other-value="
currentField.otherFieldId
? (values[currentField.otherFieldId] as string)
: undefined
"
:error-message="
errors[currentField.id] ??
(currentField.otherFieldId
? errors[currentField.otherFieldId]
: undefined)
"
@update:model-value="(value) => onFieldChange(currentField.id, value)"
@update:other-value="
(value) =>
currentField.otherFieldId &&
onFieldChange(currentField.otherFieldId, value)
"
/>
</div>
</div>
<div
v-if="!isFirst || showNext || isLast"
class="mt-8 flex items-center justify-between gap-4"
>
<div class="flex gap-6 pt-4">
<Button
v-if="!isFirst"
type="button"
variant="link"
size="lg"
class="px-0 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
variant="secondary"
class="h-10 flex-1 text-white"
@click="goPrevious"
>
<i class="icon-[lucide--chevron-left] size-4" aria-hidden="true" />
{{ $t('g.back') }}
</Button>
<span v-else />
<span v-else class="flex-1" />
<Button
v-if="showNext"
v-if="!isLast"
type="button"
size="lg"
:disabled="!isCurrentValid"
class="bg-brand-yellow text-primary-comfy-ink hover:bg-brand-yellow/85 disabled:bg-smoke-800/10 disabled:text-primary-comfy-canvas/40 disabled:opacity-100"
:class="
cn(
'h-10 flex-1 border-none',
isCurrentValid
? 'bg-electric-400 text-black hover:bg-electric-400/85'
: 'bg-zinc-800 text-zinc-500'
)
"
@click="goNext"
>
{{ $t('g.next') }}
<i class="icon-[lucide--chevron-right] size-4" aria-hidden="true" />
</Button>
<Button
v-else-if="isLast"
v-else
type="submit"
size="lg"
:disabled="!isCurrentValid || isSubmitting"
:loading="isSubmitting"
class="bg-brand-yellow text-primary-comfy-ink hover:bg-brand-yellow/85 disabled:bg-smoke-800/10 disabled:text-primary-comfy-canvas/40 disabled:opacity-100"
:class="
cn(
'h-10 flex-1 border-none',
isCurrentValid && !isSubmitting
? 'bg-electric-400 text-black hover:bg-electric-400/85'
: 'bg-zinc-800 text-zinc-500'
)
"
>
{{ $t('g.submit') }}
</Button>
<span v-else />
</div>
</form>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { cn } from '@comfyorg/tailwind-utils'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
OnboardingSurvey,
OnboardingSurveyField
} from '@/platform/remoteConfig/types'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import DynamicSurveyField from './DynamicSurveyField.vue'
import {
buildInitialValues,
buildSubmissionPayload,
buildZodSchema,
hasNonEmptyValue,
isOtherValue,
prepareSurvey,
visibleFields
} from './surveySchema'
@@ -156,8 +147,6 @@ watch(
liveValues.value = { ...fresh }
resetForm({ values: fresh })
stepIndex.value = 0
touched.value = new Set()
isAdvancing.value = false
}
)
@@ -165,43 +154,11 @@ const visible = computed(() =>
visibleFields(preparedSurvey.value, values as SurveyValues)
)
const stepIndex = ref(0)
const touched = ref(new Set<string>())
const isAdvancing = ref(false)
const questionContent = useTemplateRef<HTMLElement>('questionContent')
const { height: contentHeight } = useElementSize(questionContent)
const animatedHeightStyle = computed(() =>
contentHeight.value ? { height: `${contentHeight.value}px` } : {}
)
const currentField = computed(() => visible.value[stepIndex.value])
const isFirst = computed(() => stepIndex.value === 0)
const isLast = computed(() => stepIndex.value === visible.value.length - 1)
const showNext = computed(() => {
if (isLast.value || isAdvancing.value) return false
const field = currentField.value
if (!field) return false
if (field.type !== 'single') return true
return !(field.required && !hasNonEmptyValue(values[field.id]))
})
const currentError = computed(() => {
const field = currentField.value
if (!field) return undefined
if (touched.value.has(field.id) && errors.value[field.id]) {
return errors.value[field.id]
}
if (
field.otherFieldId &&
touched.value.has(field.otherFieldId) &&
errors.value[field.otherFieldId]
) {
return errors.value[field.otherFieldId]
}
return undefined
})
const totalSteps = computed(() => Math.max(visible.value.length, 1))
const progressPercent = computed(() =>
Math.max(
@@ -215,41 +172,26 @@ const isCurrentValid = computed(() => {
if (!field) return false
const value = values[field.id]
if (!hasNonEmptyValue(value)) return !field.required
const isEmpty =
field.type === 'multi'
? !Array.isArray(value) || value.length === 0
: typeof value !== 'string' || value.length === 0
if (field.allowOther && field.otherFieldId && isOtherValue(value)) {
if (isEmpty) return !field.required
if (field.allowOther && field.otherFieldId && value === 'other') {
const other = values[field.otherFieldId]
return typeof other === 'string' && other.trim().length > 0
}
return true
})
const isAutoAdvanceValue = (field: OnboardingSurveyField, value: unknown) =>
field.type === 'single' &&
typeof value === 'string' &&
value !== '' &&
value !== 'other'
const markTouched = (id: string) => {
touched.value = new Set(touched.value).add(id)
}
const onFieldChange = async (id: string, value: string | string[]) => {
if (isAdvancing.value) return
markTouched(id)
const onFieldChange = (id: string, value: string | string[]) => {
setFieldValue(id, value)
liveValues.value = { ...liveValues.value, [id]: value }
if (stepIndex.value > visible.value.length - 1) {
stepIndex.value = Math.max(0, visible.value.length - 1)
}
const field = currentField.value
if (field?.id === id && isAutoAdvanceValue(field, value)) {
isAdvancing.value = true
await nextTick()
goNext()
isAdvancing.value = false
}
}
const goNext = () => {
@@ -260,11 +202,6 @@ const goPrevious = () => {
}
const onSubmit = async () => {
const field = currentField.value
if (field) {
markTouched(field.id)
if (field.otherFieldId) markTouched(field.otherFieldId)
}
const result = await validate()
if (!result.valid) return
emit(

View File

@@ -1,61 +1,55 @@
import type {
OnboardingSurvey,
OnboardingSurveyOption
} from '@/platform/remoteConfig/types'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
const optionsFor = (
fieldId: string,
values: string[],
icons: Record<string, string> = {}
): OnboardingSurveyOption[] =>
values: string[]
): { value: string; labelKey: string }[] =>
values.map((value) => ({
value,
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`,
...(icons[value] ? { icon: icons[value] } : {})
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`
}))
export const defaultOnboardingSurvey: OnboardingSurvey = {
version: 3,
version: 2,
introKey: 'cloudOnboarding.survey.intro',
fields: [
{
id: 'intent',
id: 'usage',
type: 'single',
labelKey: 'cloudSurvey_steps_usage',
required: true,
options: optionsFor('usage', ['personal', 'work', 'education'])
},
{
id: 'familiarity',
type: 'single',
labelKey: 'cloudSurvey_steps_familiarity',
required: true,
options: optionsFor('familiarity', [
'new',
'starting',
'basics',
'advanced',
'expert'
])
},
{
id: 'intent',
type: 'multi',
labelKey: 'cloudSurvey_steps_intent',
required: true,
allowOther: true,
otherFieldId: 'intentOther',
options: optionsFor(
'intent',
['images', 'video', 'workflows', 'apps_api', 'exploring', 'other'],
{
images: 'icon-[lucide--image]',
video: 'icon-[lucide--video]',
workflows: 'icon-[lucide--workflow]',
apps_api: 'icon-[lucide--blocks]',
exploring: 'icon-[lucide--compass]',
other: 'icon-[lucide--pencil]'
}
)
},
{
id: 'experience',
type: 'single',
labelKey: 'cloudSurvey_steps_experience',
required: true,
options: optionsFor('experience', ['new', 'some', 'pro'], {
new: 'icon-[lucide--sprout]',
some: 'icon-[lucide--map]',
pro: 'icon-[lucide--rocket]'
})
},
{
id: 'focus',
type: 'single',
labelKey: 'cloudSurvey_steps_focus',
required: true,
showWhen: { field: 'intent', equals: ['workflows', 'apps_api'] },
options: optionsFor('focus', ['custom_nodes', 'pipelines', 'products'])
randomize: true,
options: optionsFor('intent', [
'workflows',
'custom_nodes',
'videos',
'images',
'3d_game',
'audio',
'apps',
'api',
'not_sure'
])
},
{
id: 'source',
@@ -63,31 +57,19 @@ export const defaultOnboardingSurvey: OnboardingSurvey = {
labelKey: 'cloudSurvey_steps_source',
required: true,
randomize: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: optionsFor('source', [
'social',
'friend',
'search',
'community',
'other'
])
},
{
id: 'source_social',
type: 'single',
labelKey: 'cloudSurvey_steps_source_social',
required: true,
randomize: true,
showWhen: { field: 'source', equals: 'social' },
options: optionsFor('source_social', [
'youtube',
'reddit',
'twitter',
'instagram',
'tiktok',
'linkedin',
'discord'
'friend',
'search',
'newsletter',
'conference',
'discord',
'github',
'other'
])
}
]

View File

@@ -2,13 +2,10 @@ import { describe, expect, it } from 'vitest'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import { defaultOnboardingSurvey } from './defaultSurveySchema'
import {
OTHER_TEXT_MAX_LENGTH,
buildInitialValues,
buildSubmissionPayload,
buildZodSchema,
hasNonEmptyValue,
prepareSurvey,
visibleFields
} from './surveySchema'
@@ -249,179 +246,3 @@ describe('prepareSurvey', () => {
expect(values.slice(0, -2).sort()).toEqual(['a', 'b'])
})
})
describe('defaultOnboardingSurvey branching', () => {
const idsFor = (values: Record<string, string | string[]>) =>
visibleFields(defaultOnboardingSurvey, values).map((f) => f.id)
it('asks only the core steps when no branch condition is met', () => {
expect(idsFor({ intent: 'images', source: 'friend' })).toEqual([
'intent',
'experience',
'source'
])
})
it('asks every step when both branches are active', () => {
expect(idsFor({ intent: 'workflows', source: 'social' })).toEqual([
'intent',
'experience',
'focus',
'source',
'source_social'
])
})
it('asks focus only for builder intents (workflows / apps_api)', () => {
expect(idsFor({ intent: 'workflows' })).toContain('focus')
expect(idsFor({ intent: 'apps_api' })).toContain('focus')
expect(idsFor({ intent: 'images' })).not.toContain('focus')
expect(idsFor({ intent: 'exploring' })).not.toContain('focus')
})
it('asks source_social only when source is social', () => {
expect(idsFor({ source: 'social' })).toContain('source_social')
expect(idsFor({ source: 'friend' })).not.toContain('source_social')
})
it('zeroes hidden branch fields in the submission payload', () => {
const payload = buildSubmissionPayload(defaultOnboardingSurvey, {
intent: 'images',
experience: 'new',
source: 'friend'
})
expect(payload).toMatchObject({
intent: 'images',
experience: 'new',
source: 'friend',
focus: '',
source_social: ''
})
})
it('prefers free-text over the "other" sentinel for intent and source', () => {
const payload = buildSubmissionPayload(defaultOnboardingSurvey, {
intent: 'other',
intentOther: ' Comics ',
experience: 'pro',
source: 'other',
sourceOther: 'A podcast'
})
expect(payload.intent).toBe('Comics')
expect(payload.source).toBe('A podcast')
})
})
describe('hasNonEmptyValue', () => {
const cases: [string | string[] | undefined, boolean][] = [
[undefined, false],
['', false],
[[], false],
['a', true],
[['a'], true],
[['a', 'b'], true]
]
it.for(cases)('treats %o as non-empty=%o', ([value, expected]) => {
expect(hasNonEmptyValue(value)).toBe(expected)
})
})
describe('multi-select allowOther', () => {
const multiOtherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'making',
type: 'multi',
required: true,
allowOther: true,
otherFieldId: 'makingOther',
options: [
{ value: 'a', labelKey: 'a' },
{ value: 'other', labelKey: 'other' }
]
}
]
}
it('requires the free-text when a multi field includes "other"', () => {
const schema = buildZodSchema(multiOtherSurvey, {
making: ['a', 'other'],
makingOther: ''
})
expect(
schema.safeParse({ making: ['a', 'other'], makingOther: '' }).success
).toBe(false)
expect(
schema.safeParse({ making: ['a', 'other'], makingOther: 'Comics' })
.success
).toBe(true)
})
it('does not require the free-text when "other" is not among the choices', () => {
const schema = buildZodSchema(multiOtherSurvey, {
making: ['a'],
makingOther: ''
})
expect(schema.safeParse({ making: ['a'], makingOther: '' }).success).toBe(
true
)
})
it('keeps the array and surfaces the trimmed free-text separately', () => {
const payload = buildSubmissionPayload(multiOtherSurvey, {
making: ['a', 'other'],
makingOther: ' Comics '
})
expect(payload.making).toEqual(['a', 'other'])
expect(payload.makingOther).toBe('Comics')
})
})
describe('other free-text validation', () => {
const otherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'source',
type: 'single',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', labelKey: 'search' },
{ value: 'other', labelKey: 'other' }
]
}
]
}
it('rejects a whitespace-only "other" answer', () => {
const schema = buildZodSchema(otherSurvey, {
source: 'other',
sourceOther: ' '
})
expect(
schema.safeParse({ source: 'other', sourceOther: ' ' }).success
).toBe(false)
})
it('rejects an "other" answer longer than the max length', () => {
const schema = buildZodSchema(otherSurvey, {
source: 'other',
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH + 1)
})
expect(
schema.safeParse({
source: 'other',
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH + 1)
}).success
).toBe(false)
expect(
schema.safeParse({
source: 'other',
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH)
}).success
).toBe(true)
})
})

View File

@@ -9,19 +9,12 @@ import type {
export type SurveyValues = Record<string, string | string[] | undefined>
export const OTHER_TEXT_MAX_LENGTH = 200
export const hasNonEmptyValue = (
current: string | string[] | undefined
): boolean => {
const hasNonEmptyValue = (current: string | string[] | undefined): boolean => {
if (current === undefined || current === '') return false
if (Array.isArray(current)) return current.length > 0
return true
}
export const isOtherValue = (current: string | string[] | undefined): boolean =>
Array.isArray(current) ? current.includes('other') : current === 'other'
const conditionMatches = (
condition: OnboardingSurveyFieldCondition | undefined,
values: SurveyValues
@@ -61,7 +54,7 @@ export const prepareSurvey = (survey: OnboardingSurvey): OnboardingSurvey => ({
fields: survey.fields.map(randomizeOptions)
})
type Translator = (key: string, named?: Record<string, unknown>) => string
type Translator = (key: string) => string
const identityTranslator: Translator = (key) => key
@@ -94,19 +87,11 @@ export const buildZodSchema = (
if (
field.allowOther &&
field.otherFieldId &&
isOtherValue(values[field.id])
values[field.id] === 'other'
) {
shape[field.otherFieldId] = z
.string()
.trim()
.min(1, {
message: t('cloudOnboarding.survey.errors.describeAnswer')
})
.max(OTHER_TEXT_MAX_LENGTH, {
message: t('cloudOnboarding.survey.errors.answerTooLong', {
max: OTHER_TEXT_MAX_LENGTH
})
})
shape[field.otherFieldId] = z.string().min(1, {
message: t('cloudOnboarding.survey.errors.describeAnswer')
})
} else if (field.otherFieldId) {
shape[field.otherFieldId] = z.string().optional()
}
@@ -135,23 +120,17 @@ export const buildSubmissionPayload = (
continue
}
const value = values[field.id]
const otherFieldId = field.otherFieldId
const otherRaw = otherFieldId ? values[otherFieldId] : undefined
const otherText =
const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined
if (
field.allowOther &&
otherFieldId &&
isOtherValue(value) &&
field.otherFieldId &&
value === 'other' &&
typeof otherRaw === 'string'
? otherRaw.trim()
: undefined
if (otherText !== undefined && field.type !== 'multi') {
payload[field.id] = otherText || 'other'
) {
const other = otherRaw.trim()
payload[field.id] = other || 'other'
} else {
payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '')
if (otherText !== undefined && otherFieldId) {
payload[otherFieldId] = otherText
}
}
}
return payload

View File

@@ -44,7 +44,6 @@ export type OnboardingSurveyOption = {
value: string
label?: LocalizedString
labelKey?: string
icon?: string
}
export type OnboardingSurveyFieldCondition = {

View File

@@ -41,7 +41,7 @@
<img
v-if="option.logo"
:src="option.logo"
alt=""
:alt="option.label"
class="size-4"
/>
{{ option.label }}

View File

@@ -268,27 +268,6 @@ describe('useSecretForm', () => {
])
})
it('passes a server-listed provider absent from the local registry through with its raw id as label and no logo', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => ['brand-new-provider'],
visible,
onSaved: vi.fn()
})
expect(providerOptions.value).toEqual([
{
value: 'brand-new-provider',
label: 'brand-new-provider',
logo: undefined,
disabled: false
}
])
expect(providerOptions.value[0]?.logo).toBeUndefined()
})
it('omits BYOK providers the server does not list', () => {
const visible = ref(true)
const { providerOptions } = useSecretForm({

View File

@@ -101,11 +101,8 @@ export function useSecretForm(options: UseSecretFormOptions) {
// Once the server allowlist resolves, drop a selection the resolved list no
// longer offers so the user cannot submit an unlisted provider.
watch(providerOptions, (resolvedOptions) => {
if (
form.provider &&
!resolvedOptions.some((o) => o.value === form.provider)
) {
watch(providerOptions, (options) => {
if (form.provider && !options.some((o) => o.value === form.provider)) {
form.provider = null
}
})

View File

@@ -39,11 +39,6 @@ export function useSettingsDialog() {
// breaks those nested dialogs' autofocus and click handling. Non-modal
// keeps the visual overlay without those traps.
modal: false,
// A nested dialog closing (e.g. confirming a Secrets delete) can move
// focus onto an app element once the row it focused is removed. As a
// non-modal dialog Settings would treat that as an outside focus and
// dismiss itself, so opt out — escape and outside clicks still close it.
dismissOnFocusOutside: false,
size: 'full',
contentClass: SETTINGS_CONTENT_CLASS,
overlayClass: isWorkspaceMode ? 'p-8' : undefined

View File

@@ -52,25 +52,20 @@ export interface AuthMetadata {
utm_campaign?: string
}
/** Survey field ids → answers. Fields are backend-overridable, so all optional. */
/**
* Survey response data for user profiling
* Maps 1-to-1 with actual survey fields
*/
export interface SurveyResponses {
// Current default schema (see defaultSurveySchema.ts)
intent?: string | string[]
intentOther?: string
experience?: string
focus?: string
source?: string
sourceOther?: string
source_social?: string
// Legacy fields — only emitted by older backend-supplied schemas, never by
// the current default. Kept so historical responses still typecheck.
familiarity?: string
industry?: string
useCase?: string
making?: string[]
role?: string
teamSize?: string
source?: string
usage?: string
intent?: string[]
}
export interface SurveyResponsesNormalized extends SurveyResponses {

View File

@@ -38,14 +38,6 @@ interface CustomDialogComponentProps {
pt?: DialogPassThroughOptions
closeOnEscape?: boolean
dismissableMask?: boolean
/**
* When `false`, the Reka dialog does not dismiss when focus leaves its
* content. Set on container dialogs (e.g. Settings) that host nested dialogs,
* where a nested dialog closing can move focus onto an ordinary app element
* — a programmatic shift that must not be read as a dismiss. Escape and
* outside-pointer dismissal are unaffected. Defaults to `true`.
*/
dismissOnFocusOutside?: boolean
unstyled?: boolean
headless?: boolean
renderer?: DialogRenderer

View File

@@ -7,12 +7,10 @@ import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import type { SettingParams } from '@/platform/settings/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Keybinding } from '@/platform/keybindings/types'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyCommand } from '@/stores/commandStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import type { AuthUserInfo } from '@/types/authTypes'
import type { BottomPanelExtension } from '@/types/extensionTypes'
@@ -267,9 +265,5 @@ export interface ComfyExtension {
*/
onAuthUserLogout?(): Promise<void> | void
onNodeOutputsUpdated?(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
): void
[key: string]: unknown
}

View File

@@ -142,7 +142,12 @@ describe('PackVersionSelectorPopover', () => {
})
it('shows loading state while fetching versions', async () => {
mockGetPackVersions.mockImplementationOnce(() => new Promise(() => {}))
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(() => resolve(defaultMockVersions), 1000)
)
)
renderComponent()