mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-13 01:38:10 +00:00
Compare commits
14 Commits
uy/node-se
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fa74d2d6b | ||
|
|
93ff253625 | ||
|
|
2b6cc16575 | ||
|
|
85ad748c06 | ||
|
|
4b81b127dd | ||
|
|
598cf33ab7 | ||
|
|
1b14f4df8a | ||
|
|
ef93b4696c | ||
|
|
24c512d144 | ||
|
|
6f6141a8e4 | ||
|
|
14f8fdebdd | ||
|
|
73e3aead16 | ||
|
|
c190784307 | ||
|
|
38458c518e |
@@ -1,20 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Locale,
|
||||
LocalizedText,
|
||||
TranslationKey
|
||||
} from '../../i18n/translations'
|
||||
import type { EventItem } from '../../content.config'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
|
||||
export type EventItem = {
|
||||
label: LocalizedText
|
||||
title: LocalizedText
|
||||
cta: LocalizedText
|
||||
href: string
|
||||
}
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
@@ -40,12 +30,12 @@ const {
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
@@ -66,20 +56,20 @@ const {
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
{{ event.label }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray flex-1 text-sm">
|
||||
{{ event.title[locale] }}
|
||||
{{ event.title }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
|
||||
>
|
||||
{{ event.cta[locale] }}
|
||||
{{ event.cta }}
|
||||
<svg
|
||||
class="size-4 transition-transform group-hover:translate-x-0.5"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../content.config'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GalleryItemAttribution from './GalleryItemAttribution.vue'
|
||||
|
||||
@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
|
||||
<div class="flex w-full items-end justify-between p-4">
|
||||
<div class="gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
|
||||
<!-- Mobile metadata -->
|
||||
<div v-if="mobile" class="mt-2 gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../content.config'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
@@ -123,11 +123,11 @@ onUnmounted(() => {
|
||||
<!-- Close button -->
|
||||
<button
|
||||
:aria-label="t('gallery.detail.close', locale)"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:top-8 lg:right-26"
|
||||
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:top-8 lg:right-26"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
|
||||
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
|
||||
style="mask: url('/icons/close.svg') center / contain no-repeat"
|
||||
/>
|
||||
</button>
|
||||
@@ -136,7 +136,7 @@ onUnmounted(() => {
|
||||
<div class="relative hidden min-h-0 w-full flex-1 pt-12 lg:flex">
|
||||
<!-- Left: info card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
|
||||
class="bg-primary-comfy-yellow rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8 text-primary-comfy-ink"
|
||||
>
|
||||
<div
|
||||
:class="transitioning ? 'opacity-0' : 'opacity-100'"
|
||||
@@ -170,7 +170,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Right: large image -->
|
||||
<div
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 p-4"
|
||||
class="border-primary-comfy-yellow rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-4"
|
||||
>
|
||||
<component
|
||||
:is="activeItem.video ? 'video' : 'img'"
|
||||
@@ -197,7 +197,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<!-- Image -->
|
||||
<div
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
|
||||
class="border-primary-comfy-yellow flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 bg-primary-comfy-ink p-3"
|
||||
>
|
||||
<component
|
||||
:is="activeItem.video ? 'video' : 'img'"
|
||||
@@ -223,7 +223,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Info card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
|
||||
class="bg-primary-comfy-yellow w-full rounded-4xl p-6 text-primary-comfy-ink"
|
||||
>
|
||||
<div
|
||||
:class="transitioning ? 'opacity-0' : 'opacity-100'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../content.config'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { visibleGalleryItems as items } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../content.config'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GalleryCard from './GalleryCard.vue'
|
||||
import GalleryDetailModal from './GalleryDetailModal.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const { items, locale = 'en' } = defineProps<{
|
||||
items: GalleryItem[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const modalIndex = ref(0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
|
||||
|
||||
import type { LearningTutorial } from '../../data/learningTutorials'
|
||||
import type { ResolvedTutorial } from '../../content.config'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
@@ -9,7 +9,7 @@ import { t } from '../../i18n/translations'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { tutorial, locale = 'en' } = defineProps<{
|
||||
tutorial: LearningTutorial
|
||||
tutorial: ResolvedTutorial
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
@@ -39,7 +39,7 @@ onUnmounted(() => {
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
:aria-label="tutorial.title[locale]"
|
||||
:aria-label="tutorial.title"
|
||||
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
|
||||
@click="handleBackdropClick"
|
||||
@keydown="handleKeydown"
|
||||
@@ -60,7 +60,7 @@ onUnmounted(() => {
|
||||
class="border-primary-comfy-yellow rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-3 lg:p-4"
|
||||
>
|
||||
<VideoPlayer
|
||||
:key="tutorial.id"
|
||||
:key="tutorial.slug"
|
||||
:locale
|
||||
:src="tutorial.videoSrc"
|
||||
:poster="tutorial.poster"
|
||||
@@ -73,7 +73,7 @@ onUnmounted(() => {
|
||||
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
{{ tutorial.title }}
|
||||
</h2>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ResolvedTutorial } from '../../content.config'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
getTutorialPosterSrc,
|
||||
learningTutorials
|
||||
} from '../../data/learningTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { getTutorialPosterSrc } from '../../utils/tutorial'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import MaskRevealButton from '../common/MaskRevealButton.vue'
|
||||
import TutorialDetailDialog from './TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const { tutorials, locale = 'en' } = defineProps<{
|
||||
tutorials: readonly ResolvedTutorial[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const activeTutorialId = ref<string | null>(null)
|
||||
const activeTutorialSlug = ref<string | null>(null)
|
||||
const activeTutorial = () =>
|
||||
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
|
||||
tutorials.find((tutorial) => tutorial.slug === activeTutorialSlug.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,15 +32,15 @@ const activeTutorial = () =>
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li
|
||||
v-for="tutorial in learningTutorials"
|
||||
:key="tutorial.id"
|
||||
v-for="tutorial in tutorials"
|
||||
:key="tutorial.slug"
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
|
||||
@click="activeTutorialId = tutorial.id"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title}`"
|
||||
@click="activeTutorialSlug = tutorial.slug"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
@@ -74,7 +75,7 @@ const activeTutorial = () =>
|
||||
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
|
||||
{{ tutorial.title[locale] }}
|
||||
{{ tutorial.title }}
|
||||
</h3>
|
||||
<MaskRevealButton
|
||||
v-if="tutorial.href"
|
||||
@@ -103,7 +104,7 @@ const activeTutorial = () =>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
<Badge>{{ tag }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -114,7 +115,7 @@ const activeTutorial = () =>
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
@close="activeTutorialId = null"
|
||||
@close="activeTutorialSlug = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../content.config'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
@@ -9,64 +9,14 @@ import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryCard from '../gallery/GalleryCard.vue'
|
||||
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const { items, locale = 'en' } = defineProps<{
|
||||
items: readonly GalleryItem[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const modelName = 'Grok'
|
||||
const ctaHref = 'https://comfy.org/workflows/model/grok'
|
||||
|
||||
const items: GalleryItem[] = [
|
||||
{
|
||||
id: 'subway-swan',
|
||||
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
|
||||
title: 'Subway Swan',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'milos-little-wonder',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
|
||||
title: 'Milos Little Wonder',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'amber-passage',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
|
||||
title: 'Amber Passage',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats',
|
||||
objectPosition: 'bottom'
|
||||
},
|
||||
{
|
||||
id: 'neon-revenant',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
|
||||
title: 'Neon Revenant',
|
||||
userAlias: 'Eric Solorio',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.instagram.com/enigmatic_e'
|
||||
},
|
||||
{
|
||||
id: 'midnight-umami',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
|
||||
title: 'Midnight Umami',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
}
|
||||
]
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const modalIndex = ref(0)
|
||||
|
||||
|
||||
131
apps/website/src/content.config.test.ts
Normal file
131
apps/website/src/content.config.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('astro:content', () => ({
|
||||
defineCollection: (config: unknown) => config
|
||||
}))
|
||||
vi.mock('astro/loaders', () => ({
|
||||
glob: () => ({})
|
||||
}))
|
||||
|
||||
import { eventsSchema, gallerySchema, tutorialsSchema } from './content.config'
|
||||
|
||||
const validEntry = {
|
||||
order: 1,
|
||||
title: 'Until Our Eye Interlink harajuku',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
|
||||
href: 'https://www.thinkdiffusion.com/studio'
|
||||
}
|
||||
|
||||
describe('gallerySchema', () => {
|
||||
it('accepts a valid entry', () => {
|
||||
const result = gallerySchema.safeParse(validEntry)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an entry missing a required field with a Zod error naming the field', () => {
|
||||
const { title: _omit, ...withoutTitle } = validEntry
|
||||
const result = gallerySchema.safeParse(withoutTitle)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((issue) => issue.path.join('.'))
|
||||
expect(paths).toContain('title')
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults visible to true when omitted', () => {
|
||||
const result = gallerySchema.safeParse(validEntry)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.visible).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an explicit visible: false', () => {
|
||||
const result = gallerySchema.safeParse({ ...validEntry, visible: false })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.visible).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an invalid URL in image/video/href', () => {
|
||||
const result = gallerySchema.safeParse({
|
||||
...validEntry,
|
||||
href: 'not a url'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const validEvent = {
|
||||
order: 1,
|
||||
label: 'Live Stream:',
|
||||
title: 'Zero to Node: Building Your First Workflow',
|
||||
cta: 'Link',
|
||||
href: 'https://example.com/event'
|
||||
}
|
||||
|
||||
describe('eventsSchema', () => {
|
||||
it('accepts a valid entry', () => {
|
||||
const result = eventsSchema.safeParse(validEvent)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an entry missing a required field with a Zod error naming the field', () => {
|
||||
const { title: _omit, ...withoutTitle } = validEvent
|
||||
const result = eventsSchema.safeParse(withoutTitle)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((issue) => issue.path.join('.'))
|
||||
expect(paths).toContain('title')
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts "#" as a placeholder href', () => {
|
||||
const result = eventsSchema.safeParse({ ...validEvent, href: '#' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a truly malformed href that is neither a URL nor "#"', () => {
|
||||
const result = eventsSchema.safeParse({ ...validEvent, href: 'not a url' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const validTutorial = {
|
||||
order: 1,
|
||||
tags: ['Partner Nodes', 'Image To Video'],
|
||||
title: 'Cleanplate Walkthrough',
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg'
|
||||
}
|
||||
|
||||
describe('tutorialsSchema', () => {
|
||||
it('accepts a valid entry', () => {
|
||||
const result = tutorialsSchema.safeParse(validTutorial)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an entry missing a required field with a Zod error naming the field', () => {
|
||||
const { title: _omit, ...withoutTitle } = validTutorial
|
||||
const result = tutorialsSchema.safeParse(withoutTitle)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((issue) => issue.path.join('.'))
|
||||
expect(paths).toContain('title')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an invalid videoSrc URL', () => {
|
||||
const result = tutorialsSchema.safeParse({
|
||||
...validTutorial,
|
||||
videoSrc: 'not a url'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
78
apps/website/src/content.config.ts
Normal file
78
apps/website/src/content.config.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { defineCollection } from 'astro:content'
|
||||
import { glob } from 'astro/loaders'
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
export const gallerySchema = z.object({
|
||||
order: z.number().int(),
|
||||
image: z.string().url().optional(),
|
||||
video: z.string().url().optional(),
|
||||
title: z.string(),
|
||||
userAlias: z.string(),
|
||||
teamAlias: z.string(),
|
||||
tool: z.string(),
|
||||
href: z.string().url().optional(),
|
||||
objectPosition: z.string().optional(),
|
||||
objectFit: z.string().optional(),
|
||||
visible: z.boolean().default(true)
|
||||
})
|
||||
|
||||
export type GalleryItem = z.infer<typeof gallerySchema>
|
||||
|
||||
export const eventsSchema = z.object({
|
||||
order: z.number().int(),
|
||||
label: z.string(),
|
||||
title: z.string(),
|
||||
cta: z.string(),
|
||||
href: z.string().url().or(z.literal('#'))
|
||||
})
|
||||
|
||||
export type EventItem = z.infer<typeof eventsSchema>
|
||||
|
||||
export const tutorialsSchema = z.object({
|
||||
order: z.number().int(),
|
||||
tags: z.array(z.string()),
|
||||
title: z.string(),
|
||||
videoSrc: z.string().url(),
|
||||
href: z.string().url().optional(),
|
||||
poster: z.string().url().optional(),
|
||||
posterTime: z.number().optional()
|
||||
})
|
||||
|
||||
export type LearningTutorial = z.infer<typeof tutorialsSchema>
|
||||
export type ResolvedTutorial = LearningTutorial & { slug: string }
|
||||
|
||||
// The default `generateId` lowercases path segments (e.g. `zh-CN/foo` becomes
|
||||
// `zh-cn/foo`), which collides with the BCP-47 locale codes Astro's i18n
|
||||
// config uses elsewhere. Strip the file extension to keep the path — and
|
||||
// therefore the locale prefix — verbatim.
|
||||
const preservePathId = ({ entry }: { entry: string }): string =>
|
||||
entry.replace(/\.[^.]+$/, '')
|
||||
|
||||
const gallery = defineCollection({
|
||||
loader: glob({
|
||||
pattern: '**/*.json',
|
||||
base: './src/content/gallery',
|
||||
generateId: preservePathId
|
||||
}),
|
||||
schema: gallerySchema
|
||||
})
|
||||
|
||||
const events = defineCollection({
|
||||
loader: glob({
|
||||
pattern: '**/*.json',
|
||||
base: './src/content/events',
|
||||
generateId: preservePathId
|
||||
}),
|
||||
schema: eventsSchema
|
||||
})
|
||||
|
||||
const tutorials = defineCollection({
|
||||
loader: glob({
|
||||
pattern: '**/*.md',
|
||||
base: './src/content/tutorials',
|
||||
generateId: preservePathId
|
||||
}),
|
||||
schema: tutorialsSchema
|
||||
})
|
||||
|
||||
export const collections = { gallery, events, tutorials }
|
||||
7
apps/website/src/content/events/en/event-london.json
Normal file
7
apps/website/src/content/events/en/event-london.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 2,
|
||||
"label": "Event 1",
|
||||
"title": "Lorem ipsum dollar sita met",
|
||||
"cta": "London, UK",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 3,
|
||||
"label": "Event 2",
|
||||
"title": "Lorem ipsum dollar sita met",
|
||||
"cta": "San Francisco",
|
||||
"href": "#"
|
||||
}
|
||||
7
apps/website/src/content/events/en/zero-to-node.json
Normal file
7
apps/website/src/content/events/en/zero-to-node.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 1,
|
||||
"label": "Live Stream:",
|
||||
"title": "Zero to Node: Building Your First Workflow",
|
||||
"cta": "Link",
|
||||
"href": "#"
|
||||
}
|
||||
7
apps/website/src/content/events/zh-CN/event-london.json
Normal file
7
apps/website/src/content/events/zh-CN/event-london.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 2,
|
||||
"label": "活动 1",
|
||||
"title": "此处为活动描述的占位文本",
|
||||
"cta": "英国伦敦",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 3,
|
||||
"label": "活动 2",
|
||||
"title": "此处为活动描述的占位文本",
|
||||
"cta": "旧金山",
|
||||
"href": "#"
|
||||
}
|
||||
7
apps/website/src/content/events/zh-CN/zero-to-node.json
Normal file
7
apps/website/src/content/events/zh-CN/zero-to-node.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": 1,
|
||||
"label": "直播:",
|
||||
"title": "从零到节点:构建你的第一个工作流",
|
||||
"cta": "链接",
|
||||
"href": "#"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/amber-astronaut.json
Normal file
9
apps/website/src/content/gallery/en/amber-astronaut.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 17,
|
||||
"image": "https://media.comfy.org/website/gallery/gallery.webp",
|
||||
"title": "Amber Astronaut",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
|
||||
}
|
||||
11
apps/website/src/content/gallery/en/amber-passage.json
Normal file
11
apps/website/src/content/gallery/en/amber-passage.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"order": 21,
|
||||
"image": "https://media.comfy.org/website/gallery/amber-passage_compressed.jpg",
|
||||
"title": "Amber Passage",
|
||||
"userAlias": "Purz Beats",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.youtube.com/@PurzBeats",
|
||||
"objectPosition": "bottom",
|
||||
"visible": false
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/animation-reel.json
Normal file
9
apps/website/src/content/gallery/en/animation-reel.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 16,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
|
||||
"title": "Animation Reel",
|
||||
"userAlias": "Andidea",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/autopoiesis.json
Normal file
9
apps/website/src/content/gallery/en/autopoiesis.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 5,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
|
||||
"title": "Autopoiesis",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/visualfrisson/?hl=en"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 13,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
|
||||
"title": "Cuco - A Love Letter To LA",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "CoffeeVectors",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://vimeo.com/1062859798"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/ddu-du-ddu-du.json
Normal file
9
apps/website/src/content/gallery/en/ddu-du-ddu-du.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 12,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
|
||||
"title": "DDU-DU DDU-DU",
|
||||
"userAlias": "Purz",
|
||||
"teamAlias": "Andidea",
|
||||
"tool": "Animatediff",
|
||||
"href": "https://vimeo.com/1019924290"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/desert-landing.json
Normal file
9
apps/website/src/content/gallery/en/desert-landing.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 18,
|
||||
"image": "https://media.comfy.org/website/gallery/desert.webp",
|
||||
"title": "Desert Landing",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/eat-it-dance.json
Normal file
9
apps/website/src/content/gallery/en/eat-it-dance.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 6,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
|
||||
"title": "Eat It - Dance",
|
||||
"userAlias": "Johana Lyu",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.joannalyu.com/"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/fall.json
Normal file
9
apps/website/src/content/gallery/en/fall.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 7,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
|
||||
"title": "Fall",
|
||||
"userAlias": "Nathan Shipley",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 11,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
|
||||
"title": "It's gonna be a good good summer",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "",
|
||||
"tool": "CogvideoX",
|
||||
"href": "https://vimeo.com/1019685900"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/goodbye-beijing.json
Normal file
9
apps/website/src/content/gallery/en/goodbye-beijing.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 15,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
|
||||
"title": "Goodbye Beijing",
|
||||
"userAlias": "Rui",
|
||||
"teamAlias": "makeitrad",
|
||||
"tool": "Animatediff",
|
||||
"href": "https://x.com/rui40000"
|
||||
}
|
||||
10
apps/website/src/content/gallery/en/midnight-umami.json
Normal file
10
apps/website/src/content/gallery/en/midnight-umami.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"order": 23,
|
||||
"image": "https://media.comfy.org/website/gallery/midnight_umami_compressed.png",
|
||||
"title": "Midnight Umami",
|
||||
"userAlias": "Purz Beats",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.youtube.com/@PurzBeats",
|
||||
"visible": false
|
||||
}
|
||||
10
apps/website/src/content/gallery/en/milos-little-wonder.json
Normal file
10
apps/website/src/content/gallery/en/milos-little-wonder.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"order": 20,
|
||||
"video": "https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4",
|
||||
"title": "Milos Little Wonder",
|
||||
"userAlias": "Purz Beats",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.youtube.com/@PurzBeats",
|
||||
"visible": false
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/neon-nights.json
Normal file
9
apps/website/src/content/gallery/en/neon-nights.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 3,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
|
||||
"title": "Neon Nights",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
|
||||
}
|
||||
10
apps/website/src/content/gallery/en/neon-revenant.json
Normal file
10
apps/website/src/content/gallery/en/neon-revenant.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"order": 22,
|
||||
"video": "https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4",
|
||||
"title": "Neon Revenant",
|
||||
"userAlias": "Eric Solorio",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.instagram.com/enigmatic_e",
|
||||
"visible": false
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/origami-world.json
Normal file
9
apps/website/src/content/gallery/en/origami-world.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 9,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
|
||||
"title": "Origami world",
|
||||
"userAlias": "Karen X",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/karenxcheng/"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 2,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
|
||||
"title": "Origins - Kyrie Irving",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "ThinkDiffusion",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://vimeo.com/1021360563"
|
||||
}
|
||||
9
apps/website/src/content/gallery/en/shot-on-instax.json
Normal file
9
apps/website/src/content/gallery/en/shot-on-instax.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 10,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
|
||||
"title": "Shot on InstaX",
|
||||
"userAlias": "Karen X",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/karenxcheng/"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 14,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
|
||||
"title": "Show you my garden",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "",
|
||||
"tool": "CogvideoX",
|
||||
"href": "https://vimeo.com/1019685479"
|
||||
}
|
||||
10
apps/website/src/content/gallery/en/subway-swan.json
Normal file
10
apps/website/src/content/gallery/en/subway-swan.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"order": 19,
|
||||
"image": "https://media.comfy.org/website/gallery/subway-swan_compressed.png",
|
||||
"title": "Subway Swan",
|
||||
"userAlias": "Purz Beats",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.youtube.com/@PurzBeats",
|
||||
"visible": false
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 1,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
|
||||
"title": "Until Our Eye Interlink harajuku",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "ThinkDiffusion",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 8,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
|
||||
"title": "Untitled",
|
||||
"userAlias": "Nathan Shipley",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 4,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
|
||||
"title": "Untitled",
|
||||
"userAlias": "MidJourney man",
|
||||
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 17,
|
||||
"image": "https://media.comfy.org/website/gallery/gallery.webp",
|
||||
"title": "Amber Astronaut",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 16,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
|
||||
"title": "Animation Reel",
|
||||
"userAlias": "Andidea",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
|
||||
}
|
||||
9
apps/website/src/content/gallery/zh-CN/autopoiesis.json
Normal file
9
apps/website/src/content/gallery/zh-CN/autopoiesis.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 5,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
|
||||
"title": "Autopoiesis",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/visualfrisson/?hl=en"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 13,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
|
||||
"title": "Cuco - A Love Letter To LA",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "CoffeeVectors",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://vimeo.com/1062859798"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 12,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
|
||||
"title": "DDU-DU DDU-DU",
|
||||
"userAlias": "Purz",
|
||||
"teamAlias": "Andidea",
|
||||
"tool": "Animatediff",
|
||||
"href": "https://vimeo.com/1019924290"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 18,
|
||||
"image": "https://media.comfy.org/website/gallery/desert.webp",
|
||||
"title": "Desert Landing",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
|
||||
}
|
||||
9
apps/website/src/content/gallery/zh-CN/eat-it-dance.json
Normal file
9
apps/website/src/content/gallery/zh-CN/eat-it-dance.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 6,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
|
||||
"title": "Eat It - Dance",
|
||||
"userAlias": "Johana Lyu",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.joannalyu.com/"
|
||||
}
|
||||
9
apps/website/src/content/gallery/zh-CN/fall.json
Normal file
9
apps/website/src/content/gallery/zh-CN/fall.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 7,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
|
||||
"title": "Fall",
|
||||
"userAlias": "Nathan Shipley",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 11,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
|
||||
"title": "It's gonna be a good good summer",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "",
|
||||
"tool": "CogvideoX",
|
||||
"href": "https://vimeo.com/1019685900"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 15,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
|
||||
"title": "Goodbye Beijing",
|
||||
"userAlias": "Rui",
|
||||
"teamAlias": "makeitrad",
|
||||
"tool": "Animatediff",
|
||||
"href": "https://x.com/rui40000"
|
||||
}
|
||||
9
apps/website/src/content/gallery/zh-CN/neon-nights.json
Normal file
9
apps/website/src/content/gallery/zh-CN/neon-nights.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 3,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
|
||||
"title": "Neon Nights",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 9,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
|
||||
"title": "Origami world",
|
||||
"userAlias": "Karen X",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/karenxcheng/"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 2,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
|
||||
"title": "Origins - Kyrie Irving",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "ThinkDiffusion",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://vimeo.com/1021360563"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 10,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
|
||||
"title": "Shot on InstaX",
|
||||
"userAlias": "Karen X",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/karenxcheng/"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 14,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
|
||||
"title": "Show you my garden",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "",
|
||||
"tool": "CogvideoX",
|
||||
"href": "https://vimeo.com/1019685479"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 1,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
|
||||
"title": "Until Our Eye Interlink harajuku",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "ThinkDiffusion",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 8,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
|
||||
"title": "Untitled",
|
||||
"userAlias": "Nathan Shipley",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"order": 4,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
|
||||
"title": "Untitled",
|
||||
"userAlias": "MidJourney man",
|
||||
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
|
||||
}
|
||||
264
apps/website/src/content/queries.test.ts
Normal file
264
apps/website/src/content/queries.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getCollection } from 'astro:content'
|
||||
|
||||
import {
|
||||
getEventsByLocale,
|
||||
getGalleryByIds,
|
||||
getTutorialsByLocale,
|
||||
getVisibleGalleryByLocale,
|
||||
slugOf
|
||||
} from './queries'
|
||||
|
||||
vi.mock('astro:content', () => ({
|
||||
getCollection: vi.fn()
|
||||
}))
|
||||
|
||||
const getCollectionMock = vi.mocked(getCollection)
|
||||
|
||||
interface FixtureEntry {
|
||||
id: string
|
||||
collection: 'gallery'
|
||||
data: {
|
||||
order: number
|
||||
title: string
|
||||
userAlias: string
|
||||
teamAlias: string
|
||||
tool: string
|
||||
image?: string
|
||||
video?: string
|
||||
href?: string
|
||||
objectPosition?: string
|
||||
objectFit?: string
|
||||
visible: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function entry(
|
||||
id: string,
|
||||
overrides: Partial<FixtureEntry['data']> = {}
|
||||
): FixtureEntry {
|
||||
return {
|
||||
id,
|
||||
collection: 'gallery',
|
||||
data: {
|
||||
order: 0,
|
||||
title: 'Title',
|
||||
userAlias: 'User',
|
||||
teamAlias: 'Team',
|
||||
tool: 'ComfyUI',
|
||||
visible: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getCollectionMock.mockReset()
|
||||
})
|
||||
|
||||
describe('slugOf', () => {
|
||||
it('strips the locale prefix from an entry id', () => {
|
||||
const entry = { id: 'en/until-our-eye-interlink-harajuku' }
|
||||
expect(slugOf(entry)).toBe('until-our-eye-interlink-harajuku')
|
||||
})
|
||||
|
||||
it('handles zh-CN prefix', () => {
|
||||
const entry = { id: 'zh-CN/desert-landing' }
|
||||
expect(slugOf(entry)).toBe('desert-landing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVisibleGalleryByLocale', () => {
|
||||
it('returns only entries whose id starts with the requested locale prefix', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
entry('en/alpha'),
|
||||
entry('en/beta'),
|
||||
entry('zh-CN/alpha'),
|
||||
entry('zh-CN/gamma')
|
||||
] as never)
|
||||
|
||||
const en = await getVisibleGalleryByLocale('en')
|
||||
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
|
||||
|
||||
const zh = await getVisibleGalleryByLocale('zh-CN')
|
||||
expect(zh.map((e) => e.id)).toEqual(['zh-CN/alpha', 'zh-CN/gamma'])
|
||||
})
|
||||
|
||||
it('excludes entries with visible: false', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
entry('en/shown', { visible: true }),
|
||||
entry('en/hidden', { visible: false }),
|
||||
entry('en/also-shown', { visible: true })
|
||||
] as never)
|
||||
|
||||
const result = await getVisibleGalleryByLocale('en')
|
||||
expect(result.map((e) => e.id)).toEqual(['en/shown', 'en/also-shown'])
|
||||
})
|
||||
|
||||
it('sorts entries by the order field ascending, not by id', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
entry('en/charlie', { order: 1 }),
|
||||
entry('en/alpha', { order: 3 }),
|
||||
entry('en/bravo', { order: 2 })
|
||||
] as never)
|
||||
|
||||
const result = await getVisibleGalleryByLocale('en')
|
||||
expect(result.map((e) => e.id)).toEqual([
|
||||
'en/charlie',
|
||||
'en/bravo',
|
||||
'en/alpha'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGalleryByIds', () => {
|
||||
it('returns entries in the order of the input slug array', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
entry('en/alpha'),
|
||||
entry('en/beta'),
|
||||
entry('en/gamma'),
|
||||
entry('zh-CN/alpha')
|
||||
] as never)
|
||||
|
||||
const result = await getGalleryByIds(['gamma', 'alpha', 'beta'], 'en')
|
||||
expect(result.map((e) => e.id)).toEqual(['en/gamma', 'en/alpha', 'en/beta'])
|
||||
})
|
||||
|
||||
it('drops slugs with no matching entry in the requested locale', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
entry('en/alpha'),
|
||||
entry('zh-CN/beta')
|
||||
] as never)
|
||||
|
||||
const result = await getGalleryByIds(['alpha', 'missing', 'beta'], 'en')
|
||||
expect(result.map((e) => e.id)).toEqual(['en/alpha'])
|
||||
})
|
||||
})
|
||||
|
||||
interface EventsFixtureEntry {
|
||||
id: string
|
||||
collection: 'events'
|
||||
data: {
|
||||
order: number
|
||||
label: string
|
||||
title: string
|
||||
cta: string
|
||||
href: string
|
||||
}
|
||||
}
|
||||
|
||||
function eventEntry(
|
||||
id: string,
|
||||
overrides: Partial<EventsFixtureEntry['data']> = {}
|
||||
): EventsFixtureEntry {
|
||||
return {
|
||||
id,
|
||||
collection: 'events',
|
||||
data: {
|
||||
order: 0,
|
||||
label: 'Label',
|
||||
title: 'Title',
|
||||
cta: 'CTA',
|
||||
href: '#',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('getEventsByLocale', () => {
|
||||
it('returns only entries whose id starts with the requested locale prefix', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
eventEntry('en/alpha'),
|
||||
eventEntry('en/beta'),
|
||||
eventEntry('zh-CN/alpha')
|
||||
] as never)
|
||||
|
||||
const en = await getEventsByLocale('en')
|
||||
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
|
||||
})
|
||||
|
||||
it('sorts entries by the order field ascending, not by id', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
eventEntry('en/charlie', { order: 1 }),
|
||||
eventEntry('en/alpha', { order: 3 }),
|
||||
eventEntry('en/bravo', { order: 2 })
|
||||
] as never)
|
||||
|
||||
const result = await getEventsByLocale('en')
|
||||
expect(result.map((e) => e.id)).toEqual([
|
||||
'en/charlie',
|
||||
'en/bravo',
|
||||
'en/alpha'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty array for a locale with no entries', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
eventEntry('en/alpha'),
|
||||
eventEntry('en/beta')
|
||||
] as never)
|
||||
|
||||
const result = await getEventsByLocale('zh-CN')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
interface TutorialsFixtureEntry {
|
||||
id: string
|
||||
collection: 'tutorials'
|
||||
data: {
|
||||
order: number
|
||||
tags: string[]
|
||||
title: string
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
posterTime?: number
|
||||
}
|
||||
}
|
||||
|
||||
function tutorialEntry(
|
||||
id: string,
|
||||
overrides: Partial<TutorialsFixtureEntry['data']> = {}
|
||||
): TutorialsFixtureEntry {
|
||||
return {
|
||||
id,
|
||||
collection: 'tutorials',
|
||||
data: {
|
||||
order: 0,
|
||||
tags: ['Tag'],
|
||||
title: 'Title',
|
||||
videoSrc: 'https://example.com/video.mp4',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('getTutorialsByLocale', () => {
|
||||
it('returns only entries whose id starts with the requested locale prefix', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
tutorialEntry('en/alpha'),
|
||||
tutorialEntry('en/beta'),
|
||||
tutorialEntry('zh-CN/alpha')
|
||||
] as never)
|
||||
|
||||
const en = await getTutorialsByLocale('en')
|
||||
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
|
||||
})
|
||||
|
||||
it('sorts entries by the order field ascending, not by id', async () => {
|
||||
getCollectionMock.mockResolvedValue([
|
||||
tutorialEntry('en/charlie', { order: 1 }),
|
||||
tutorialEntry('en/alpha', { order: 3 }),
|
||||
tutorialEntry('en/bravo', { order: 2 })
|
||||
] as never)
|
||||
|
||||
const result = await getTutorialsByLocale('en')
|
||||
expect(result.map((e) => e.id)).toEqual([
|
||||
'en/charlie',
|
||||
'en/bravo',
|
||||
'en/alpha'
|
||||
])
|
||||
})
|
||||
})
|
||||
61
apps/website/src/content/queries.ts
Normal file
61
apps/website/src/content/queries.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getCollection } from 'astro:content'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
|
||||
export type GalleryEntry = CollectionEntry<'gallery'>
|
||||
export type EventsEntry = CollectionEntry<'events'>
|
||||
export type TutorialsEntry = CollectionEntry<'tutorials'>
|
||||
|
||||
export function slugOf(entry: { id: string }): string {
|
||||
const slash = entry.id.indexOf('/')
|
||||
return slash === -1 ? entry.id : entry.id.slice(slash + 1)
|
||||
}
|
||||
|
||||
export async function getVisibleGalleryByLocale(
|
||||
locale: Locale
|
||||
): Promise<GalleryEntry[]> {
|
||||
const prefix = `${locale}/`
|
||||
const entries: GalleryEntry[] = await getCollection('gallery')
|
||||
return entries
|
||||
.filter(
|
||||
(entry) => entry.id.startsWith(prefix) && entry.data.visible !== false
|
||||
)
|
||||
.sort((a, b) => a.data.order - b.data.order)
|
||||
}
|
||||
|
||||
export async function getGalleryByIds(
|
||||
slugs: readonly string[],
|
||||
locale: Locale
|
||||
): Promise<GalleryEntry[]> {
|
||||
const entries: GalleryEntry[] = await getCollection('gallery')
|
||||
const bySlug = new Map<string, GalleryEntry>()
|
||||
for (const entry of entries) {
|
||||
if (entry.id.startsWith(`${locale}/`)) {
|
||||
bySlug.set(slugOf(entry), entry)
|
||||
}
|
||||
}
|
||||
return slugs
|
||||
.map((slug) => bySlug.get(slug))
|
||||
.filter((entry): entry is GalleryEntry => entry !== undefined)
|
||||
}
|
||||
|
||||
export async function getEventsByLocale(
|
||||
locale: Locale
|
||||
): Promise<EventsEntry[]> {
|
||||
const prefix = `${locale}/`
|
||||
const entries: EventsEntry[] = await getCollection('events')
|
||||
return entries
|
||||
.filter((entry) => entry.id.startsWith(prefix))
|
||||
.sort((a, b) => a.data.order - b.data.order)
|
||||
}
|
||||
|
||||
export async function getTutorialsByLocale(
|
||||
locale: Locale
|
||||
): Promise<TutorialsEntry[]> {
|
||||
const prefix = `${locale}/`
|
||||
const entries: TutorialsEntry[] = await getCollection('tutorials')
|
||||
return entries
|
||||
.filter((entry) => entry.id.startsWith(prefix))
|
||||
.sort((a, b) => a.data.order - b.data.order)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
order: 1
|
||||
tags:
|
||||
- Partner Nodes
|
||||
- Image To Video
|
||||
title: Cleanplate Walkthrough
|
||||
videoSrc: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg
|
||||
---
|
||||
10
apps/website/src/content/tutorials/en/deaging-workflow.md
Normal file
10
apps/website/src/content/tutorials/en/deaging-workflow.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 2
|
||||
tags:
|
||||
- Partner Nodes
|
||||
- Image To Video
|
||||
title: Deaging Workflow
|
||||
videoSrc: https://media.comfy.org/website/learning/deaging_workflow_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg
|
||||
href: https://cloud.comfy.org/?share=93f286fbc2c8
|
||||
---
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 3
|
||||
tags:
|
||||
- Partner Nodes
|
||||
- Image To Video
|
||||
title: Frame Adjustments Demo
|
||||
videoSrc: https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg
|
||||
href: https://cloud.comfy.org/?share=7dca0438edf4
|
||||
---
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 4
|
||||
tags:
|
||||
- Partner Nodes
|
||||
- Image To Video
|
||||
title: Mattes and Utilities
|
||||
videoSrc: https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg
|
||||
href: https://cloud.comfy.org/?share=be0889296f65
|
||||
---
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 5
|
||||
tags:
|
||||
- Partner Nodes
|
||||
- Image To Video
|
||||
title: Seedance Demo ComfyUI
|
||||
videoSrc: https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg
|
||||
href: https://cloud.comfy.org/?share=ef543bd4a773
|
||||
---
|
||||
10
apps/website/src/content/tutorials/en/sky-replacement.md
Normal file
10
apps/website/src/content/tutorials/en/sky-replacement.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 6
|
||||
tags:
|
||||
- Partner Nodes
|
||||
- Image To Video
|
||||
title: Sky Replacement
|
||||
videoSrc: https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4
|
||||
poster: https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg
|
||||
href: https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
order: 1
|
||||
tags:
|
||||
- 合作伙伴节点
|
||||
- 图像生成视频
|
||||
title: 净板演练
|
||||
videoSrc: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg
|
||||
---
|
||||
10
apps/website/src/content/tutorials/zh-CN/deaging-workflow.md
Normal file
10
apps/website/src/content/tutorials/zh-CN/deaging-workflow.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 2
|
||||
tags:
|
||||
- 合作伙伴节点
|
||||
- 图像生成视频
|
||||
title: 减龄工作流
|
||||
videoSrc: https://media.comfy.org/website/learning/deaging_workflow_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg
|
||||
href: https://cloud.comfy.org/?share=93f286fbc2c8
|
||||
---
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 3
|
||||
tags:
|
||||
- 合作伙伴节点
|
||||
- 图像生成视频
|
||||
title: 帧调整演示
|
||||
videoSrc: https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg
|
||||
href: https://cloud.comfy.org/?share=7dca0438edf4
|
||||
---
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 4
|
||||
tags:
|
||||
- 合作伙伴节点
|
||||
- 图像生成视频
|
||||
title: 遮罩与实用工具
|
||||
videoSrc: https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg
|
||||
href: https://cloud.comfy.org/?share=be0889296f65
|
||||
---
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 5
|
||||
tags:
|
||||
- 合作伙伴节点
|
||||
- 图像生成视频
|
||||
title: Seedance ComfyUI 演示
|
||||
videoSrc: https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4
|
||||
poster: https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg
|
||||
href: https://cloud.comfy.org/?share=ef543bd4a773
|
||||
---
|
||||
10
apps/website/src/content/tutorials/zh-CN/sky-replacement.md
Normal file
10
apps/website/src/content/tutorials/zh-CN/sky-replacement.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
order: 6
|
||||
tags:
|
||||
- 合作伙伴节点
|
||||
- 图像生成视频
|
||||
title: 天空替换
|
||||
videoSrc: https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4
|
||||
poster: https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg
|
||||
href: https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/
|
||||
---
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { EventItem } from '../components/common/EventsSection.vue'
|
||||
|
||||
export const learningEvents: readonly EventItem[] = [
|
||||
{
|
||||
label: { en: 'Live Stream:', 'zh-CN': '直播:' },
|
||||
title: {
|
||||
en: 'Zero to Node: Building Your First Workflow',
|
||||
'zh-CN': '从零到节点:构建你的第一个工作流'
|
||||
},
|
||||
cta: { en: 'Link', 'zh-CN': '链接' },
|
||||
href: '#'
|
||||
},
|
||||
{
|
||||
label: { en: 'Event 1', 'zh-CN': '活动 1' },
|
||||
title: {
|
||||
en: 'Lorem ipsum dollar sita met',
|
||||
'zh-CN': '此处为活动描述的占位文本'
|
||||
},
|
||||
cta: { en: 'London, UK', 'zh-CN': '英国伦敦' },
|
||||
href: '#'
|
||||
},
|
||||
{
|
||||
label: { en: 'Event 2', 'zh-CN': '活动 2' },
|
||||
title: {
|
||||
en: 'Lorem ipsum dollar sita met',
|
||||
'zh-CN': '此处为活动描述的占位文本'
|
||||
},
|
||||
cta: { en: 'San Francisco', 'zh-CN': '旧金山' },
|
||||
href: '#'
|
||||
}
|
||||
] as const
|
||||
@@ -1,191 +0,0 @@
|
||||
export interface GalleryItem {
|
||||
id: string
|
||||
image?: string
|
||||
video?: string
|
||||
title: string
|
||||
userAlias: string
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
objectPosition?: string
|
||||
objectFit?: string
|
||||
/** Defaults to true. Set to false to hide this item from rendered lists. */
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
const galleryItems: GalleryItem[] = [
|
||||
{
|
||||
id: 'until-our-eye-interlink-harajuku',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
|
||||
title: 'Until Our Eye Interlink harajuku',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
|
||||
},
|
||||
{
|
||||
id: 'origins-kyrie-irving',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
|
||||
title: 'Origins - Kyrie Irving',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1021360563'
|
||||
},
|
||||
{
|
||||
id: 'neon-nights',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
|
||||
title: 'Neon Nights',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
|
||||
},
|
||||
{
|
||||
id: 'untitled-dusk-mountains',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'MidJourney man',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
|
||||
},
|
||||
{
|
||||
id: 'autopoiesis',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
|
||||
title: 'Autopoiesis',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/visualfrisson/?hl=en'
|
||||
},
|
||||
{
|
||||
id: 'eat-it-dance',
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
|
||||
title: 'Eat It - Dance',
|
||||
userAlias: 'Johana Lyu',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.joannalyu.com/'
|
||||
},
|
||||
{
|
||||
id: 'fall',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
|
||||
title: 'Fall',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
|
||||
},
|
||||
{
|
||||
id: 'untitled-buildings',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
|
||||
},
|
||||
{
|
||||
id: 'origami-world',
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
|
||||
title: 'Origami world',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
id: 'shot-on-instax',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
|
||||
title: 'Shot on InstaX',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
id: 'good-good-summer',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
|
||||
title: "It's gonna be a good good summer",
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685900'
|
||||
},
|
||||
{
|
||||
id: 'ddu-du-ddu-du',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
|
||||
title: 'DDU-DU DDU-DU',
|
||||
userAlias: 'Purz',
|
||||
teamAlias: 'Andidea',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://vimeo.com/1019924290'
|
||||
},
|
||||
{
|
||||
id: 'cuco-love-letter-to-la',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
|
||||
title: 'Cuco - A Love Letter To LA',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: 'CoffeeVectors',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1062859798'
|
||||
},
|
||||
{
|
||||
id: 'show-you-my-garden',
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
|
||||
title: 'Show you my garden',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685479'
|
||||
},
|
||||
{
|
||||
id: 'goodbye-beijing',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
|
||||
title: 'Goodbye Beijing',
|
||||
userAlias: 'Rui',
|
||||
teamAlias: 'makeitrad',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://x.com/rui40000'
|
||||
},
|
||||
{
|
||||
id: 'animation-reel',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
|
||||
title: 'Animation Reel',
|
||||
userAlias: 'Andidea',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
|
||||
},
|
||||
{
|
||||
id: 'amber-astronaut',
|
||||
image: 'https://media.comfy.org/website/gallery/gallery.webp',
|
||||
title: 'Amber Astronaut',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
},
|
||||
{
|
||||
id: 'desert-landing',
|
||||
image: 'https://media.comfy.org/website/gallery/desert.webp',
|
||||
title: 'Desert Landing',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
}
|
||||
]
|
||||
|
||||
export const visibleGalleryItems: GalleryItem[] = galleryItems.filter(
|
||||
(item) => item.visible !== false
|
||||
)
|
||||
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export function getGalleryItemById(id: string): GalleryItem | undefined {
|
||||
return galleryItems.find((item) => item.id === id)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { LocalizedText, TranslationKey } from '../i18n/translations'
|
||||
|
||||
export interface LearningTutorial {
|
||||
id: string
|
||||
tags: readonly TranslationKey[]
|
||||
title: LocalizedText
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
posterTime?: number
|
||||
}
|
||||
|
||||
const DEFAULT_POSTER_TIME_SECONDS = 1
|
||||
|
||||
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
|
||||
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
|
||||
|
||||
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
|
||||
tutorial.poster
|
||||
? tutorial.poster
|
||||
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
|
||||
|
||||
export const learningTutorials: readonly LearningTutorial[] = [
|
||||
{
|
||||
id: 'cleanplate_walkthrough_v03',
|
||||
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
|
||||
// href: '#',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'deaging_workflow_v03',
|
||||
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'frame_adjustments_demo_v03',
|
||||
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'mattes_and_utilities_v03',
|
||||
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=be0889296f65',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'seedance_demo_comfyui_v03',
|
||||
title: { en: 'Seedance Demo ComfyUI', 'zh-CN': 'Seedance ComfyUI 演示' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
id: 'skyreplacement_smaller_v06',
|
||||
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
}
|
||||
] as const
|
||||
@@ -3,10 +3,14 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/gallery/HeroSection.vue'
|
||||
import GallerySection from '../components/gallery/GallerySection.vue'
|
||||
import ContactSection from '../components/gallery/ContactSection.vue'
|
||||
import { getVisibleGalleryByLocale } from '../content/queries'
|
||||
|
||||
const entries = await getVisibleGalleryByLocale('en')
|
||||
const items = entries.map((entry) => entry.data)
|
||||
---
|
||||
|
||||
<BaseLayout title="Gallery — Comfy">
|
||||
<HeroSection />
|
||||
<GallerySection client:load />
|
||||
<GallerySection items={items} client:load />
|
||||
<ContactSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -8,14 +8,20 @@ import CallToActionSection from '../components/common/CallToActionSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
import { externalLinks } from '../config/routes'
|
||||
// import { learningEvents } from '../data/events'
|
||||
import { getTutorialsByLocale, slugOf } from '../content/queries'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
const tutorialEntries = await getTutorialsByLocale('en')
|
||||
const tutorials = tutorialEntries.map((entry) => ({
|
||||
slug: slugOf(entry),
|
||||
...entry.data
|
||||
}))
|
||||
---
|
||||
|
||||
<BaseLayout title="Learning — Comfy">
|
||||
<HeroSection client:load />
|
||||
<FeaturedWorkflowSection client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<TutorialsSection tutorials={tutorials} client:visible />
|
||||
<CallToActionSection
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
|
||||
@@ -3,7 +3,18 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
|
||||
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
import { getGalleryByIds } from '../content/queries'
|
||||
|
||||
const featuredCreationSlugs = [
|
||||
'subway-swan',
|
||||
'milos-little-wonder',
|
||||
'amber-passage',
|
||||
'neon-revenant',
|
||||
'midnight-umami'
|
||||
]
|
||||
const featuredEntries = await getGalleryByIds(featuredCreationSlugs, 'en')
|
||||
const featuredItems = featuredEntries.map((entry) => entry.data)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -16,7 +27,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="Grok Imagine output created with ComfyUI"
|
||||
/>
|
||||
<ModelCreationsSection client:load />
|
||||
<ModelCreationsSection items={featuredItems} client:load />
|
||||
<AIModelsSection client:load />
|
||||
<ProductShowcaseSection client:load />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -3,10 +3,14 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/gallery/HeroSection.vue'
|
||||
import GallerySection from '../../components/gallery/GallerySection.vue'
|
||||
import ContactSection from '../../components/gallery/ContactSection.vue'
|
||||
import { getVisibleGalleryByLocale } from '../../content/queries'
|
||||
|
||||
const entries = await getVisibleGalleryByLocale('zh-CN')
|
||||
const items = entries.map((entry) => entry.data)
|
||||
---
|
||||
|
||||
<BaseLayout title="作品集 — Comfy">
|
||||
<HeroSection locale="zh-CN" />
|
||||
<GallerySection locale="zh-CN" client:load />
|
||||
<GallerySection locale="zh-CN" items={items} client:load />
|
||||
<ContactSection locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -6,15 +6,34 @@ import TutorialsSection from '../../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../../components/common/CallToActionSection.vue'
|
||||
import EventsSection from '../../components/common/EventsSection.vue'
|
||||
import { getRoutes, externalLinks } from '../../config/routes'
|
||||
import { learningEvents } from '../../data/events'
|
||||
import {
|
||||
getEventsByLocale,
|
||||
getTutorialsByLocale,
|
||||
slugOf
|
||||
} from '../../content/queries'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
const eventEntries = await getEventsByLocale('zh-CN')
|
||||
const events = eventEntries.map((entry) => entry.data)
|
||||
const tutorialEntries = await getTutorialsByLocale('zh-CN')
|
||||
const tutorials = tutorialEntries.map((entry) => ({
|
||||
slug: slugOf(entry),
|
||||
...entry.data
|
||||
}))
|
||||
---
|
||||
|
||||
<BaseLayout title="学习 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<FeaturedWorkflowSection locale="zh-CN" client:visible />
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<TutorialsSection locale="zh-CN" tutorials={tutorials} client:visible />
|
||||
<EventsSection
|
||||
locale="zh-CN"
|
||||
headingKey="learning.events.heading"
|
||||
descriptionKey="learning.events.description"
|
||||
notifyLabelKey="learning.events.getNotified"
|
||||
events={events}
|
||||
client:visible
|
||||
/>
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="learning.cta.heading"
|
||||
|
||||
@@ -4,6 +4,17 @@ import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
|
||||
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
|
||||
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
|
||||
import { getGalleryByIds } from '../../content/queries'
|
||||
|
||||
const featuredCreationSlugs = [
|
||||
'subway-swan',
|
||||
'milos-little-wonder',
|
||||
'amber-passage',
|
||||
'neon-revenant',
|
||||
'midnight-umami'
|
||||
]
|
||||
const featuredEntries = await getGalleryByIds(featuredCreationSlugs, 'en')
|
||||
const featuredItems = featuredEntries.map((entry) => entry.data)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -17,7 +28,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
|
||||
/>
|
||||
<ModelCreationsSection client:load locale="zh-CN" />
|
||||
<ModelCreationsSection items={featuredItems} client:load locale="zh-CN" />
|
||||
<AIModelsSection client:load locale="zh-CN" />
|
||||
<ProductShowcaseSection client:load locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
40
apps/website/src/utils/tutorial.test.ts
Normal file
40
apps/website/src/utils/tutorial.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getTutorialPosterSrc } from './tutorial'
|
||||
|
||||
describe('getTutorialPosterSrc', () => {
|
||||
it('returns the explicit poster URL when provided', () => {
|
||||
expect(
|
||||
getTutorialPosterSrc({
|
||||
order: 1,
|
||||
tags: [],
|
||||
title: 'T',
|
||||
videoSrc: 'https://example.com/v.mp4',
|
||||
poster: 'https://example.com/poster.jpg'
|
||||
})
|
||||
).toBe('https://example.com/poster.jpg')
|
||||
})
|
||||
|
||||
it('falls back to videoSrc#t=<posterTime> when poster is missing', () => {
|
||||
expect(
|
||||
getTutorialPosterSrc({
|
||||
order: 1,
|
||||
tags: [],
|
||||
title: 'T',
|
||||
videoSrc: 'https://example.com/v.mp4',
|
||||
posterTime: 7
|
||||
})
|
||||
).toBe('https://example.com/v.mp4#t=7')
|
||||
})
|
||||
|
||||
it('uses the default poster time when neither poster nor posterTime is set', () => {
|
||||
expect(
|
||||
getTutorialPosterSrc({
|
||||
order: 1,
|
||||
tags: [],
|
||||
title: 'T',
|
||||
videoSrc: 'https://example.com/v.mp4'
|
||||
})
|
||||
).toBe('https://example.com/v.mp4#t=1')
|
||||
})
|
||||
})
|
||||
8
apps/website/src/utils/tutorial.ts
Normal file
8
apps/website/src/utils/tutorial.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LearningTutorial } from '../content.config'
|
||||
|
||||
const DEFAULT_POSTER_TIME_SECONDS = 1
|
||||
|
||||
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
|
||||
tutorial.poster
|
||||
? tutorial.poster
|
||||
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
|
||||
48
browser_tests/assets/missing/missing_nodes_same_pack.json
Normal file
48
browser_tests/assets/missing/missing_nodes_same_pack.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "TEST_MISSING_PACK_NODE_A",
|
||||
"pos": [48, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "TEST_MISSING_PACK_NODE_B",
|
||||
"pos": [520, 86],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
|
||||
"cnr_id": "test-missing-node-pack"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -45,6 +45,8 @@ export const TestIds = {
|
||||
errorOverlayMessages: 'error-overlay-messages',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
missingNodePackExpand: 'missing-node-pack-expand',
|
||||
missingNodePackCount: 'missing-node-pack-count',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
errorDialog: 'error-dialog',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
@@ -12,27 +12,39 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
test('Should show missing node pack card with guidance', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
await expect(
|
||||
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
test('Should show unknown pack node rows by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
|
||||
await expect(
|
||||
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show subgraph missing node rows by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -43,66 +55,72 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
missingNodeCard.getByRole('button', {
|
||||
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
test('Should locate missing node from the row label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
|
||||
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /collapse/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeHidden()
|
||||
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBeforeLocate)
|
||||
})
|
||||
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
test('Should toggle grouped pack nodes from chevron and title', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
'missing/missing_nodes_same_pack'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const locateButton = missingNodeCard.getByRole('button', {
|
||||
name: /locate/i
|
||||
const packTitle = missingNodeCard.getByRole('button', {
|
||||
name: 'test-missing-node-pack'
|
||||
})
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
// TODO: Add navigation assertion once subgraph node ID deduplication
|
||||
// timing is fixed. Currently, collectMissingNodes runs before
|
||||
// configure(), so execution IDs use pre-remapped node IDs that don't
|
||||
// match the runtime graph. See PR #9510 / #8762.
|
||||
const expandButton = missingNodeCard.getByTestId(
|
||||
TestIds.dialogs.missingNodePackExpand
|
||||
)
|
||||
const firstNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_A'
|
||||
})
|
||||
const secondNode = missingNodeCard.getByRole('button', {
|
||||
name: 'TEST_MISSING_PACK_NODE_B'
|
||||
})
|
||||
|
||||
await expect(packTitle).toBeVisible()
|
||||
await expect(
|
||||
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
|
||||
).toHaveText('2')
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
|
||||
await expandButton.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeHidden()
|
||||
await expect(secondNode).toBeHidden()
|
||||
|
||||
await packTitle.click()
|
||||
await expect(firstNode).toBeVisible()
|
||||
await expect(secondNode).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -425,6 +425,56 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Refreshing a resolved promoted missing model clears the combo invalid state',
|
||||
{ tag: ['@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
.getByRole('combobox', { name: 'ckpt_name', exact: true })
|
||||
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
|
||||
|
||||
const objectInfoRoute = /\/object_info$/
|
||||
try {
|
||||
await comfyPage.page.route(objectInfoRoute, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
.click()
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
await expect(promotedModelCombo).toBeVisible()
|
||||
await expect(promotedModelCombo).not.toHaveAttribute(
|
||||
'aria-invalid',
|
||||
'true'
|
||||
)
|
||||
} finally {
|
||||
await comfyPage.page.unroute(objectInfoRoute)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
101
browser_tests/tests/workspaceSwitcher.spec.ts
Normal file
101
browser_tests/tests/workspaceSwitcher.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
|
||||
const LONG_WORKSPACE_NAME =
|
||||
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
|
||||
|
||||
// text-sm rows render a single 20px line; a wrapped name is 40px+.
|
||||
const SINGLE_LINE_MAX_HEIGHT_PX = 28
|
||||
|
||||
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
|
||||
|
||||
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
role: 'owner'
|
||||
},
|
||||
{
|
||||
id: 'ws-team-long',
|
||||
name: LONG_WORKSPACE_NAME,
|
||||
type: 'team',
|
||||
created_at: '2026-01-02T00:00:00Z',
|
||||
joined_at: '2026-01-02T00:00:00Z',
|
||||
role: 'member'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenResponse: WorkspaceTokenResponse = {
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: {
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal'
|
||||
},
|
||||
role: 'owner',
|
||||
permissions: []
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRemoteConfig)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockListWorkspacesResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/auth/token', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Workspace switcher', { tag: '@cloud' }, () => {
|
||||
test('renders a long team workspace name on a single line', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
await page.getByText(PERSONAL_WORKSPACE_NAME).click()
|
||||
|
||||
const longName = page.getByText(LONG_WORKSPACE_NAME)
|
||||
await expect(longName).toBeVisible()
|
||||
|
||||
const box = await longName.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
|
||||
})
|
||||
})
|
||||
@@ -421,6 +421,15 @@ export default defineConfig([
|
||||
}
|
||||
},
|
||||
|
||||
// Astro virtual modules (astro:content, astro:assets, etc.) are not
|
||||
// resolvable by the TS resolver — they are injected by the Astro build.
|
||||
{
|
||||
files: ['apps/website/**/*.{ts,vue,astro}'],
|
||||
rules: {
|
||||
'import-x/no-unresolved': ['error', { ignore: ['^astro:'] }]
|
||||
}
|
||||
},
|
||||
|
||||
// i18n import enforcement
|
||||
// Vue components must use the useI18n() composable, not the global t/d/st/te
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.11",
|
||||
"version": "1.46.12",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<svg width="520" height="520" viewBox="0 0 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_227_285" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="520" height="520">
|
||||
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#EEFF30"/>
|
||||
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#F2FF59"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<rect y="0.751831" width="520" height="520" fill="#172DD7"/>
|
||||
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F0FF41"/>
|
||||
<rect y="0.751831" width="520" height="520" fill="#211927"/>
|
||||
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -2,8 +2,8 @@
|
||||
"PreviewImage": 4314,
|
||||
"LoadImage": 3474,
|
||||
"CLIPTextEncode": 2435,
|
||||
"SaveImageAdvanced": 1763,
|
||||
"SaveImage": 1762,
|
||||
"SaveImageAdvanced": 1762,
|
||||
"VAEDecode": 1754,
|
||||
"KSampler": 1511,
|
||||
"CheckpointLoaderSimple": 1293,
|
||||
@@ -14,6 +14,7 @@
|
||||
"UpscaleModelLoader": 629,
|
||||
"UNETLoader": 606,
|
||||
"VAELoader": 604,
|
||||
"PreviewAny": 528,
|
||||
"ShowText|pysssss": 527.5526981023964,
|
||||
"ImageUpscaleWithModel": 523,
|
||||
"ControlNetApplyAdvanced": 513,
|
||||
@@ -24,10 +25,12 @@
|
||||
"VHS_LoadVideo": 440,
|
||||
"ImpactSwitch": 349,
|
||||
"Reroute": 348,
|
||||
"ResizeImageMaskNode": 337,
|
||||
"ResizeAndPadImage": 336,
|
||||
"ImageResizeKJv2": 335,
|
||||
"StringConcatenate": 326,
|
||||
"Text Concatenate": 325.7030402103206,
|
||||
"SaveVideo": 321,
|
||||
"PreviewAny": 319,
|
||||
"KSamplerAdvanced": 304,
|
||||
"SDXLPromptStyler": 297.0913411304729,
|
||||
"Note": 291,
|
||||
@@ -52,6 +55,7 @@
|
||||
"CLIPLoader": 202,
|
||||
"GeminiNode": 202,
|
||||
"KSampler (Efficient)": 194.01083622636423,
|
||||
"RemoveBackground": 187,
|
||||
"ImageRemoveBackground+": 186,
|
||||
"IPAdapterModelLoader": 184,
|
||||
"PrimitiveInt": 183,
|
||||
@@ -59,7 +63,9 @@
|
||||
"LoadVideo": 179,
|
||||
"Text Concatenate (JPS)": 175.98154639522735,
|
||||
"PrimitiveNode": 175,
|
||||
"Text Multiline": 163.04749064680308,
|
||||
"PrimitiveStringMultiline": 166,
|
||||
"Text Multiline": 165,
|
||||
"GetImageSize": 164,
|
||||
"GetImageSize+": 163,
|
||||
"ImageScaleToTotalPixels": 157,
|
||||
"String Literal": 150.11343489837878,
|
||||
@@ -68,15 +74,14 @@
|
||||
"DownloadAndLoadFlorence2Model": 144,
|
||||
"LoadImageOutput": 143,
|
||||
"IPAdapterUnifiedLoader": 141,
|
||||
"FluxGuidance": 133,
|
||||
"BatchImagesNode": 134,
|
||||
"ImageBatchMulti": 133,
|
||||
"FluxGuidance": 132,
|
||||
"ByteDanceSeedreamNode": 130,
|
||||
"CR Text Input Switch": 128.16473423438606,
|
||||
"IPAdapterAdvanced": 128,
|
||||
"If ANY execute A else B": 127.77279315110049,
|
||||
"GeminiImage2Node": 124,
|
||||
"GetImageSize": 121,
|
||||
"PrimitiveStringMultiline": 120,
|
||||
"IPAdapter": 118,
|
||||
"CreateVideo": 116,
|
||||
"ConditioningZeroOut": 115,
|
||||
@@ -102,6 +107,7 @@
|
||||
"DepthAnythingPreprocessor": 100,
|
||||
"CR Apply LoRA Stack": 96.02556540496816,
|
||||
"Image Filter Adjustments": 95.24168323839699,
|
||||
"ComfyMathExpression": 96,
|
||||
"SimpleMath+": 95,
|
||||
"GroundingDinoSAMSegment (segment anything)": 93.28197782196906,
|
||||
"Image Overlay": 93.28197782196906,
|
||||
@@ -147,7 +153,6 @@
|
||||
"Image Resize": 63.494455492264656,
|
||||
"Automatic CFG": 63.494455492264656,
|
||||
"Canny": 63,
|
||||
"StringConcatenate": 63,
|
||||
"DepthAnything_V2": 61,
|
||||
"ImageCrop+": 60,
|
||||
"ModelSamplingSD3": 59,
|
||||
@@ -199,6 +204,7 @@
|
||||
"BNK_CLIPTextEncodeAdvanced": 45.857106744413365,
|
||||
"CR SDXL Aspect Ratio": 45.46516566112778,
|
||||
"LoadAudio": 45,
|
||||
"ResolutionSelector": 45,
|
||||
"smZ CLIPTextEncode": 44.68128349455661,
|
||||
"Bus Node": 44.68128349455661,
|
||||
"PreviewTextNode": 44.68128349455661,
|
||||
@@ -389,7 +395,6 @@
|
||||
"SD_4XUpscale_Conditioning": 21,
|
||||
"UltimateSDUpscaleCustomSample": 21,
|
||||
"StyleModelLoader": 21,
|
||||
"ResizeAndPadImage": 21,
|
||||
"Text Random Prompt": 20.77287741413597,
|
||||
"INPAINT_VAEEncodeInpaintConditioning": 20.77287741413597,
|
||||
"BrushNet": 20.77287741413597,
|
||||
|
||||
@@ -71,12 +71,11 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
|
||||
name: 'MissingPackGroupRow',
|
||||
template: `<div class="pack-row" data-testid="pack-row"
|
||||
:data-show-info-button="String(showInfoButton)"
|
||||
:data-show-node-id-badge="String(showNodeIdBadge)"
|
||||
>
|
||||
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
|
||||
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
|
||||
</div>`,
|
||||
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
|
||||
props: ['group', 'showInfoButton'],
|
||||
emits: ['locate-node', 'open-manager-info']
|
||||
}
|
||||
}))
|
||||
@@ -122,7 +121,6 @@ function makePackGroups(count = 2): MissingPackGroup[] {
|
||||
function renderCard(
|
||||
props: Partial<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}> = {}
|
||||
) {
|
||||
@@ -130,7 +128,6 @@ function renderCard(
|
||||
const result = render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
...props
|
||||
},
|
||||
@@ -169,12 +166,10 @@ describe('MissingNodeCard', () => {
|
||||
|
||||
it('passes props correctly to MissingPackGroupRow children', () => {
|
||||
renderCard({
|
||||
showInfoButton: true,
|
||||
showNodeIdBadge: true
|
||||
showInfoButton: true
|
||||
})
|
||||
const row = screen.getAllByTestId('pack-row')[0]
|
||||
expect(row.getAttribute('data-show-info-button')).toBe('true')
|
||||
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,7 +251,6 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onLocateNode
|
||||
},
|
||||
@@ -279,7 +273,6 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onOpenManagerInfo
|
||||
},
|
||||
|
||||
@@ -56,27 +56,29 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
|
||||
<div v-if="shouldShowManagerButtons" class="px-4">
|
||||
<Button
|
||||
v-if="hasInstalledPacksPendingRestart"
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
@@ -105,9 +107,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||
|
||||
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
|
||||
const { showInfoButton, missingPackGroups } = defineProps<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}>()
|
||||
|
||||
|
||||
@@ -61,16 +61,16 @@ const i18n = createI18n({
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
loading: 'Loading'
|
||||
install: 'Install',
|
||||
loading: 'Loading',
|
||||
search: 'Search'
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate node on canvas',
|
||||
missingNodePacks: {
|
||||
unknownPack: 'Unknown pack',
|
||||
installNodePack: 'Install node pack',
|
||||
installing: 'Installing...',
|
||||
installed: 'Installed',
|
||||
searchInManager: 'Search in Node Manager',
|
||||
viewInManager: 'View in Manager',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand'
|
||||
@@ -100,7 +100,6 @@ function renderRow(
|
||||
props: Partial<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
@@ -110,7 +109,6 @@ function renderRow(
|
||||
props: {
|
||||
group: makeGroup(),
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
onLocateNode,
|
||||
onOpenManagerInfo,
|
||||
...props
|
||||
@@ -118,7 +116,6 @@ function renderRow(
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
stubs: {
|
||||
TransitionCollapse: { template: '<div><slot /></div>' },
|
||||
DotSpinner: {
|
||||
template: '<span role="status" aria-label="loading" />'
|
||||
}
|
||||
@@ -156,9 +153,22 @@ describe('MissingPackGroupRow', () => {
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render header locate while pack metadata is resolving', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
isResolving: true,
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Locate node on canvas' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node count', () => {
|
||||
renderRow()
|
||||
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders count of 5 for 5 nodeTypes', () => {
|
||||
@@ -171,38 +181,29 @@ describe('MissingPackGroupRow', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Expand / Collapse', () => {
|
||||
it('starts collapsed', () => {
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands when chevron is clicked', async () => {
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses when chevron is clicked again', async () => {
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type List', () => {
|
||||
async function expand(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
}
|
||||
it('hides multiple nodeTypes behind the expand control by default', () => {
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all nodeTypes when expanded', async () => {
|
||||
it('shows unknown pack nodeTypes by default', () => {
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Collapse' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all nodeTypes after expanding', async () => {
|
||||
const { user } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
@@ -212,40 +213,87 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
expect(screen.getByText('NodeA')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeB')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows nodeId badge when showNodeIdBadge is true', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
it('hides multiple nodeTypes again after collapsing', async () => {
|
||||
const { user } = renderRow()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides nodeId badge when showNodeIdBadge is false', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: false })
|
||||
await expand(user)
|
||||
expect(screen.queryByText('#10')).not.toBeInTheDocument()
|
||||
it('hides a single nodeType without an expand control', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Expand' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits locateNode when Locate button is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
|
||||
const { user, onLocateNode } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'my-pack' }))
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('100')
|
||||
})
|
||||
|
||||
it('moves locate to the header when there is one nodeType', async () => {
|
||||
const { user, onLocateNode } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Locate node on canvas' })
|
||||
)
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('100')
|
||||
})
|
||||
|
||||
it('emits locateNode when expanded child Locate button is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
|
||||
)
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not show Locate for nodeType without nodeId', async () => {
|
||||
const { user } = renderRow({
|
||||
it('emits locateNode when node label is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
await user.click(screen.getByRole('button', { name: 'MissingA' }))
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not show Locate for nodeType without nodeId', () => {
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Locate node on canvas' })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -253,7 +301,6 @@ describe('MissingPackGroupRow', () => {
|
||||
|
||||
it('handles mixed nodeTypes with and without nodeId', async () => {
|
||||
const { user } = renderRow({
|
||||
showNodeIdBadge: true,
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
{ type: 'WithId', nodeId: '100', isReplaceable: false },
|
||||
@@ -261,7 +308,7 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('WithId')).toBeInTheDocument()
|
||||
expect(screen.getByText('WithoutId')).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -274,21 +321,25 @@ describe('MissingPackGroupRow', () => {
|
||||
it('hides install UI when shouldShowManagerButtons is false', () => {
|
||||
mockShouldShowManagerButtons.value = false
|
||||
renderRow()
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides install UI when packId is null', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
|
||||
it('shows Search when packId exists but pack not in registry', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = []
|
||||
renderRow()
|
||||
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Installed" state when pack is installed', () => {
|
||||
@@ -312,7 +363,9 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
renderRow()
|
||||
expect(screen.getByText('Install node pack')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Install' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls installAllPacks when Install button is clicked', async () => {
|
||||
@@ -320,9 +373,7 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const { user } = renderRow()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Install node pack/ })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Install' }))
|
||||
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -369,7 +420,7 @@ describe('MissingPackGroupRow', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty nodeTypes array', () => {
|
||||
renderRow({ group: makeGroup({ nodeTypes: [] }) })
|
||||
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,187 +1,221 @@
|
||||
<template>
|
||||
<div class="mb-2 flex w-full flex-col">
|
||||
<!-- Pack header row: pack name + info + chevron -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<!-- Warning icon for unknown packs -->
|
||||
<i
|
||||
v-if="group.packId === null && !group.isResolving"
|
||||
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:class="
|
||||
group.packId === null && !group.isResolving
|
||||
? 'text-warning-background'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<Button
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="missing-node-pack-expand"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
{ 'rotate-180': expanded }
|
||||
)
|
||||
"
|
||||
size="unset"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Sub-labels: individual node instances, each with their own Locate button -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
|
||||
>
|
||||
<div
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
showNodeIdBadge &&
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null
|
||||
<i
|
||||
v-if="isUnknownPack"
|
||||
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 items-center gap-2.5">
|
||||
<button
|
||||
v-if="hasMultipleNodeTypes && !group.isResolving"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
isUnknownPack
|
||||
? 'text-warning-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
:aria-expanded="expanded"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
#{{ nodeType.nodeId }}
|
||||
{{ packDisplayName }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="primaryLocatableNodeType"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
isUnknownPack
|
||||
? 'text-warning-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
{{ packDisplayName }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ packDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getLabel(nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3" />
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
|
||||
<div
|
||||
v-if="
|
||||
shouldShowManagerButtons &&
|
||||
group.packId !== null &&
|
||||
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
|
||||
"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
:disabled="
|
||||
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
|
||||
"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
|
||||
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
|
||||
/>
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: comfyManagerStore.isPackInstalled(group.packId)
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('rightSidePanel.missingNodePacks.installNodePack')
|
||||
}}
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Registry still loading: packId known but result not yet available -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
</span>
|
||||
<div v-if="showInstallAction" class="ml-auto shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<span class="text-foreground min-w-0 truncate">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: isPackInstalled
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('g.install')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search in Manager: fetch done but pack not found in registry -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="text-foreground min-w-0 truncate">
|
||||
{{ t('g.search') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
|
||||
</span>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
<ul
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
'text-muted-foreground hover:text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
@@ -193,10 +227,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
|
||||
const { group, showInfoButton } = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -205,6 +238,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
@@ -219,17 +256,73 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
|
||||
nodePack.value ? [nodePack.value] : []
|
||||
)
|
||||
|
||||
const isUnknownPack = computed(
|
||||
() => group.packId === null && !group.isResolving
|
||||
)
|
||||
|
||||
const packDisplayName = computed(() => {
|
||||
if (group.packId === null) {
|
||||
return t('rightSidePanel.missingNodePacks.unknownPack')
|
||||
}
|
||||
return nodePack.value?.name ?? group.packId
|
||||
})
|
||||
|
||||
const isPackInstalled = computed(
|
||||
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
|
||||
)
|
||||
|
||||
const showInstallAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
(nodePack.value !== null || isPackInstalled.value)
|
||||
)
|
||||
|
||||
const showLoadingAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
!showInstallAction.value &&
|
||||
isLoading.value
|
||||
)
|
||||
|
||||
const showSearchAction = computed(
|
||||
() =>
|
||||
shouldShowManagerButtons.value &&
|
||||
group.packId !== null &&
|
||||
!showInstallAction.value &&
|
||||
!showLoadingAction.value
|
||||
)
|
||||
|
||||
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
|
||||
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
|
||||
const expanded = computed(
|
||||
() =>
|
||||
expandedOverride.value ??
|
||||
(isUnknownPack.value && hasMultipleNodeTypes.value)
|
||||
)
|
||||
const showNodeTypeList = computed(
|
||||
() =>
|
||||
(isUnknownPack.value && group.nodeTypes.length === 1) ||
|
||||
(hasMultipleNodeTypes.value && expanded.value)
|
||||
)
|
||||
const primaryLocatableNodeType = computed(() => {
|
||||
if (group.isResolving) return null
|
||||
if (isUnknownPack.value) return null
|
||||
if (group.nodeTypes.length !== 1) return null
|
||||
const [nodeType] = group.nodeTypes
|
||||
return isLocatableNodeType(nodeType) ? nodeType : null
|
||||
})
|
||||
|
||||
function handlePackInstallClick() {
|
||||
if (!group.packId) return
|
||||
if (!comfyManagerStore.isPackInstalled(group.packId)) {
|
||||
if (!isPackInstalled.value) {
|
||||
void installAllPacks()
|
||||
}
|
||||
}
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpand() {
|
||||
expanded.value = !expanded.value
|
||||
expandedOverride.value = !expanded.value
|
||||
}
|
||||
|
||||
function getKey(nodeType: MissingNodeType): string {
|
||||
@@ -241,10 +334,14 @@ function getLabel(nodeType: MissingNodeType): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function isLocatableNodeType(
|
||||
nodeType: MissingNodeType
|
||||
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
|
||||
return typeof nodeType !== 'string' && nodeType.nodeId != null
|
||||
}
|
||||
|
||||
function handleLocateNode(nodeType: MissingNodeType) {
|
||||
if (typeof nodeType === 'string') return
|
||||
if (nodeType.nodeId != null) {
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
if (!isLocatableNodeType(nodeType)) return
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
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 type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
|
||||
import type {
|
||||
LinkReleaseNodeCategory,
|
||||
LinkReleaseSearchResultGroup
|
||||
} from './linkReleaseMenuModel'
|
||||
|
||||
const { groups } = vi.hoisted(() => ({
|
||||
groups: {
|
||||
suggestions: [] as ComfyNodeDefImpl[],
|
||||
categories: [] as LinkReleaseNodeCategory[],
|
||||
searchResultGroups: [] as LinkReleaseSearchResultGroup[]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./linkReleaseMenuModel', () => ({
|
||||
getLinkReleaseHeaderLabel: () => '',
|
||||
getLinkReleaseSuggestions: () => groups.suggestions,
|
||||
buildLinkReleaseNodeCategories: () => groups.categories,
|
||||
groupLinkReleaseSearchResults: () => groups.searchResultGroups,
|
||||
searchLinkReleaseNodes: () =>
|
||||
groups.searchResultGroups.flatMap((group) =>
|
||||
group.nodes.map((node) => ({ category: group.category, node }))
|
||||
),
|
||||
filterNodesByName: () => []
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
const stubs = {
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuLabel: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div role="menuitem"><slot /></div>' },
|
||||
DropdownMenuSeparator: { template: '<hr data-testid="menu-separator" />' },
|
||||
LinkReleaseNodeSubmenu: { template: '<div data-testid="submenu" />' },
|
||||
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
|
||||
}
|
||||
|
||||
function suggestion(name: string): ComfyNodeDefImpl {
|
||||
return { name, display_name: name } as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function nodeCategory(
|
||||
key: 'comfy' | 'extensions' | 'partner',
|
||||
labelKey: string = key
|
||||
): LinkReleaseNodeCategory {
|
||||
return { key, labelKey, icon: '', nodes: [suggestion('Node')] }
|
||||
}
|
||||
|
||||
function renderMenu() {
|
||||
return render(LinkReleaseContextMenu, {
|
||||
props: { context: null },
|
||||
global: { plugins: [i18n, createTestingPinia()], stubs }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinkReleaseContextMenu group divider', () => {
|
||||
it('renders a divider between the suggestions and categories groups', () => {
|
||||
groups.suggestions = [suggestion('KSampler')]
|
||||
groups.categories = [nodeCategory('comfy')]
|
||||
renderMenu()
|
||||
|
||||
expect(screen.getAllByTestId('menu-separator')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('omits the group divider when only one group is present', () => {
|
||||
groups.suggestions = []
|
||||
groups.categories = [nodeCategory('comfy')]
|
||||
renderMenu()
|
||||
|
||||
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders a divider between search result groups', async () => {
|
||||
groups.suggestions = []
|
||||
groups.categories = []
|
||||
groups.searchResultGroups = [
|
||||
{
|
||||
category: nodeCategory('extensions', 'contextMenu.Extensions'),
|
||||
nodes: [suggestion('Ext Node')]
|
||||
},
|
||||
{
|
||||
category: nodeCategory('partner', 'contextMenu.Partner Nodes'),
|
||||
nodes: [suggestion('Partner Node')]
|
||||
}
|
||||
]
|
||||
renderMenu()
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox'), 'na')
|
||||
|
||||
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -1,379 +0,0 @@
|
||||
<template>
|
||||
<DropdownMenuRoot :open="open" :modal="false" @update:open="onOpenChange">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed size-0"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="SIDE_OFFSET"
|
||||
:collision-padding="VIEWPORT_MARGIN"
|
||||
:avoid-collisions="false"
|
||||
:class="contentClass"
|
||||
:style="menuMaxHeight ? { maxHeight: `${menuMaxHeight}px` } : undefined"
|
||||
@open-auto-focus.prevent="focusSearch"
|
||||
@close-auto-focus.prevent
|
||||
@entry-focus="onEntryFocus"
|
||||
@keydown.capture="redirectTypingToSearch"
|
||||
>
|
||||
<DropdownMenuLabel
|
||||
v-if="headerLabel"
|
||||
class="flex shrink-0 items-center gap-2 p-2 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<span
|
||||
class="size-4 rounded-full"
|
||||
:style="{ backgroundColor: slotColor }"
|
||||
/>
|
||||
</span>
|
||||
<span class="truncate">{{ headerLabel }}</span>
|
||||
</DropdownMenuLabel>
|
||||
<div data-search-field class="p-.5 shrink-0">
|
||||
<div
|
||||
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
type="text"
|
||||
:placeholder="t('contextMenu.Search')"
|
||||
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
|
||||
@keydown="onRootSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
|
||||
<div :class="scrollClass">
|
||||
<template v-if="trimmedQuery">
|
||||
<template
|
||||
v-for="(group, groupIndex) in searchResultGroups"
|
||||
:key="group.category.key"
|
||||
>
|
||||
<DropdownMenuSeparator
|
||||
v-if="groupIndex > 0"
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
:class="itemClass"
|
||||
@select="selectNode(node)"
|
||||
>
|
||||
<i
|
||||
:class="cn(group.category.icon, 'size-4 shrink-0 opacity-80')"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span class="shrink-0 text-muted-foreground">
|
||||
{{ t(group.category.labelKey) }}:
|
||||
</span>
|
||||
<MiddleTruncate
|
||||
:text="node.display_name"
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<div
|
||||
v-if="searchResults.length === 0"
|
||||
class="p-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResults') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="suggestions.length">
|
||||
<DropdownMenuLabel
|
||||
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ t('contextMenu.Most Relevant') }}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
v-for="nodeDef in suggestions"
|
||||
:key="nodeDef.name"
|
||||
:class="itemClass"
|
||||
@select="selectNode(nodeDef)"
|
||||
>
|
||||
<MiddleTruncate
|
||||
:text="nodeDef.display_name"
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<DropdownMenuSeparator
|
||||
v-if="suggestions.length && categories.length"
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
|
||||
<template v-if="categories.length">
|
||||
<DropdownMenuLabel
|
||||
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ t('contextMenu.Compatible Nodes') }}
|
||||
</DropdownMenuLabel>
|
||||
<LinkReleaseNodeSubmenu
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
:category
|
||||
:item-class="itemClass"
|
||||
:content-class="submenuContentClass"
|
||||
:scroll-class="submenuScrollClass"
|
||||
@select="selectNode"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="!trimmedQuery">
|
||||
<DropdownMenuSeparator
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
:class="cn(itemClass, 'shrink-0')"
|
||||
@select="addReroute"
|
||||
>
|
||||
<i class="icon-[lucide--git-fork] size-4 shrink-0 opacity-80" />
|
||||
<span class="flex-1 truncate">
|
||||
{{ t('contextMenu.Add Reroute') }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
|
||||
import MiddleTruncate from './MiddleTruncate.vue'
|
||||
import {
|
||||
buildLinkReleaseNodeCategories,
|
||||
computeContextMenuTop,
|
||||
estimateLinkReleaseMenuHeight,
|
||||
getLinkReleaseHeaderLabel,
|
||||
getLinkReleaseSuggestions,
|
||||
groupLinkReleaseSearchResults,
|
||||
searchLinkReleaseNodes
|
||||
} from './linkReleaseMenuModel'
|
||||
import type {
|
||||
LinkReleaseContext,
|
||||
LinkReleaseNodeMatch
|
||||
} from './linkReleaseMenuModel'
|
||||
|
||||
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectNode: [nodeDef: ComfyNodeDefImpl]
|
||||
addReroute: []
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const open = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const searchInput = ref<HTMLInputElement>()
|
||||
const query = ref('')
|
||||
const menuMaxHeight = ref<number>()
|
||||
let actionTaken = false
|
||||
|
||||
const VIEWPORT_MARGIN = 8
|
||||
const SIDE_OFFSET = 4
|
||||
const MENU_WIDTH = 384
|
||||
|
||||
const contentClass =
|
||||
'z-1700 flex min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const scrollClass =
|
||||
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
|
||||
const submenuContentClass =
|
||||
'z-1700 flex w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const submenuScrollClass =
|
||||
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
|
||||
const itemClass =
|
||||
'flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
|
||||
|
||||
const headerLabel = computed(() =>
|
||||
context ? getLinkReleaseHeaderLabel(context) : ''
|
||||
)
|
||||
|
||||
const slotColor = computed(() => getSlotColor(context?.dataType?.split(',')[0]))
|
||||
|
||||
const trimmedQuery = computed(() => query.value.trim())
|
||||
|
||||
const typeFilter = computed(() => {
|
||||
if (!context) return null
|
||||
const svc = nodeDefStore.nodeSearchService
|
||||
return {
|
||||
filterDef: context.isFromOutput
|
||||
? svc.inputTypeFilter
|
||||
: svc.outputTypeFilter,
|
||||
value: context.dataType
|
||||
}
|
||||
})
|
||||
|
||||
const compatibleNodes = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!typeFilter.value) return []
|
||||
return nodeDefStore.nodeSearchService.searchNode('', [typeFilter.value], {
|
||||
limit: 500
|
||||
})
|
||||
})
|
||||
|
||||
const defaultNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!context?.dataType) return []
|
||||
const table = context.isFromOutput
|
||||
? LiteGraph.slot_types_default_out
|
||||
: LiteGraph.slot_types_default_in
|
||||
const types = table?.[context.dataType] ?? []
|
||||
return types
|
||||
.map((type) => nodeDefStore.allNodeDefsByName[type])
|
||||
.filter((nodeDef): nodeDef is ComfyNodeDefImpl => Boolean(nodeDef))
|
||||
})
|
||||
|
||||
const suggestions = computed(() =>
|
||||
getLinkReleaseSuggestions(defaultNodeDefs.value)
|
||||
)
|
||||
const categories = computed(() =>
|
||||
buildLinkReleaseNodeCategories(compatibleNodes.value)
|
||||
)
|
||||
|
||||
const searchResultGroups = computed(() =>
|
||||
groupLinkReleaseSearchResults(categories.value, trimmedQuery.value)
|
||||
)
|
||||
const searchResults = computed<LinkReleaseNodeMatch[]>(() =>
|
||||
searchLinkReleaseNodes(categories.value, trimmedQuery.value)
|
||||
)
|
||||
|
||||
function selectNode(nodeDef: ComfyNodeDefImpl) {
|
||||
actionTaken = true
|
||||
emit('selectNode', nodeDef)
|
||||
hide()
|
||||
}
|
||||
|
||||
function addReroute() {
|
||||
actionTaken = true
|
||||
emit('addReroute')
|
||||
hide()
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
function isPrintableKey(event: KeyboardEvent) {
|
||||
return (
|
||||
event.key.length === 1 &&
|
||||
event.key !== ' ' &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
)
|
||||
}
|
||||
|
||||
// When the keyboard focus is on a menu item, funnel printable keystrokes into
|
||||
// the search field instead of letting Reka run its item type-ahead.
|
||||
function redirectTypingToSearch(event: KeyboardEvent) {
|
||||
if (event.target === searchInput.value || !isPrintableKey(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
query.value += event.key
|
||||
focusSearch()
|
||||
}
|
||||
|
||||
// Reka refocuses the first item (scrolling the list to the top) whenever the
|
||||
// menu regains focus, which fires as the pointer leaves an item while scrolling.
|
||||
function onEntryFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function focusFirstItem(target: HTMLElement) {
|
||||
const menu = target.closest<HTMLElement>('[role="menu"]')
|
||||
menu
|
||||
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
|
||||
?.focus()
|
||||
}
|
||||
|
||||
function onRootSearchKeydown(event: KeyboardEvent) {
|
||||
// Let Reka close the menu natively on Escape.
|
||||
if (event.key === 'Escape') return
|
||||
event.stopPropagation()
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusFirstItem(event.currentTarget as HTMLElement)
|
||||
} else if (event.key === 'Enter' && trimmedQuery.value) {
|
||||
const first = searchResults.value[0]
|
||||
if (first) selectNode(first.node)
|
||||
}
|
||||
}
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
actionTaken = false
|
||||
query.value = ''
|
||||
const menuHeight = estimateLinkReleaseMenuHeight({
|
||||
hasHeader: Boolean(headerLabel.value),
|
||||
suggestionCount: suggestions.value.length,
|
||||
categoryCount: categories.value.length,
|
||||
searchResultCount: 0,
|
||||
showReroute: true
|
||||
})
|
||||
const menuTop = computeContextMenuTop({
|
||||
cursorY: event.clientY,
|
||||
menuHeight,
|
||||
viewportHeight: window.innerHeight,
|
||||
margin: VIEWPORT_MARGIN,
|
||||
sideOffset: SIDE_OFFSET
|
||||
})
|
||||
menuMaxHeight.value = window.innerHeight - menuTop - VIEWPORT_MARGIN
|
||||
const maxX = window.innerWidth - MENU_WIDTH - VIEWPORT_MARGIN
|
||||
position.value = {
|
||||
x: Math.min(event.clientX, Math.max(VIEWPORT_MARGIN, maxX)),
|
||||
y: menuTop - SIDE_OFFSET
|
||||
}
|
||||
void nextTick(() => {
|
||||
open.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
open.value = value
|
||||
if (value) return
|
||||
if (!actionTaken) emit('dismiss')
|
||||
actionTaken = false
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
|
||||
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
|
||||
|
||||
const contentClass =
|
||||
'z-1700 flex max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const submenuContentClass =
|
||||
'z-1700 flex w-sm max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const submenuScrollClass =
|
||||
'overflow-y-auto scrollbar-custom max-h-[min(calc(var(--reka-dropdown-menu-content-available-height)-3.5rem),80vh)]'
|
||||
const itemClass =
|
||||
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
|
||||
|
||||
function node(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return { name, display_name } as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
const category: LinkReleaseNodeCategory = {
|
||||
key: 'comfy',
|
||||
labelKey: 'contextMenu.Comfy Nodes',
|
||||
icon: 'icon-[lucide--box]',
|
||||
nodes: [
|
||||
node('KSampler'),
|
||||
node('VAEDecode', 'VAE Decode'),
|
||||
node('VAEEncode', 'VAE Encode'),
|
||||
node('CLIPTextEncode', 'CLIP Text Encode'),
|
||||
node('LoadImage', 'Load Image'),
|
||||
node('SaveImage', 'Save Image'),
|
||||
node('EmptyLatentImage', 'Empty Latent Image'),
|
||||
node(
|
||||
'StableCascade_StageB_Conditioning',
|
||||
'StableCascade_StageB_Conditioning'
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const meta: Meta<typeof LinkReleaseNodeSubmenu> = {
|
||||
title: 'Components/Searchbox/LinkReleaseNodeSubmenu',
|
||||
component: LinkReleaseNodeSubmenu
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function renderAnchored(side: 'left' | 'right'): Story['render'] {
|
||||
return () => ({
|
||||
components: {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
LinkReleaseNodeSubmenu
|
||||
},
|
||||
setup() {
|
||||
const anchorStyle =
|
||||
side === 'right'
|
||||
? 'position: fixed; top: 64px; right: 16px;'
|
||||
: 'position: fixed; top: 64px; left: 16px;'
|
||||
return {
|
||||
anchorStyle,
|
||||
contentClass,
|
||||
submenuContentClass,
|
||||
submenuScrollClass,
|
||||
itemClass,
|
||||
category,
|
||||
side
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="height: 480px;">
|
||||
<DropdownMenuRoot default-open>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button :style="anchorStyle" class="rounded-md border border-interface-menu-stroke bg-interface-menu-surface px-3 py-1.5 text-sm text-base-foreground">
|
||||
Compatible Nodes
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:class="contentClass"
|
||||
:side="side === 'right' ? 'bottom' : 'bottom'"
|
||||
:align="side === 'right' ? 'end' : 'start'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="block truncate px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase">
|
||||
Compatible Nodes
|
||||
</DropdownMenuLabel>
|
||||
<LinkReleaseNodeSubmenu
|
||||
:category="category"
|
||||
:item-class="itemClass"
|
||||
:content-class="submenuContentClass"
|
||||
:scroll-class="submenuScrollClass"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** Anchored near the LEFT edge: the submenu opens to the RIGHT (normal). */
|
||||
export const OpensRight: Story = { render: renderAnchored('left') }
|
||||
|
||||
/**
|
||||
* Anchored near the RIGHT edge: with no room on the right, Floating UI flips the
|
||||
* submenu to the LEFT, landing flush against the parent menu's left edge.
|
||||
*/
|
||||
export const FlipsLeft: Story = { render: renderAnchored('right') }
|
||||
@@ -1,67 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
|
||||
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
const category: LinkReleaseNodeCategory = {
|
||||
key: 'comfy',
|
||||
labelKey: 'Comfy Nodes',
|
||||
icon: 'icon-[lucide--box]',
|
||||
nodes: [{ name: 'KSampler', display_name: 'KSampler' } as ComfyNodeDefImpl]
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
DropdownMenuSub: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSubTrigger: {
|
||||
template: '<button data-testid="sub-trigger"><slot /></button>'
|
||||
},
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSubContent: { template: '<div role="menu"><slot /></div>' },
|
||||
DropdownMenuSeparator: { template: '<hr />' },
|
||||
DropdownMenuItem: { template: '<div role="menuitem"><slot /></div>' },
|
||||
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
|
||||
}
|
||||
|
||||
function renderSubmenu() {
|
||||
return render(LinkReleaseNodeSubmenu, {
|
||||
props: { category, itemClass: '', contentClass: '', scrollClass: '' },
|
||||
global: { plugins: [i18n], stubs }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinkReleaseNodeSubmenu keyboard handling', () => {
|
||||
it('steps into the submenu search on ArrowRight', async () => {
|
||||
renderSubmenu()
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('steps into the submenu search on Enter', async () => {
|
||||
renderSubmenu()
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not move focus to the search on other keys', async () => {
|
||||
renderSubmenu()
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('a')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('textbox')).not.toHaveFocus()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user