mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-12 01:09:36 +00:00
Compare commits
9 Commits
refactor/c
...
synap5e/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9649228bd | ||
|
|
bf481f2c71 | ||
|
|
45fef1d89c | ||
|
|
cf996d8e39 | ||
|
|
ebf70db149 | ||
|
|
6c0c603576 | ||
|
|
a13d6cf99e | ||
|
|
c7873ac7ed | ||
|
|
87625d852b |
@@ -1,10 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { EventItem } from '../../content.config'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import type {
|
||||
Locale,
|
||||
LocalizedText,
|
||||
TranslationKey
|
||||
} from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
|
||||
export type EventItem = {
|
||||
label: LocalizedText
|
||||
title: LocalizedText
|
||||
cta: LocalizedText
|
||||
href: string
|
||||
}
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
@@ -30,12 +40,12 @@ const {
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
@@ -56,20 +66,20 @@ const {
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
>
|
||||
{{ event.label }}
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray flex-1 text-sm">
|
||||
{{ event.title }}
|
||||
{{ event.title[locale] }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
|
||||
>
|
||||
{{ event.cta }}
|
||||
{{ event.cta[locale] }}
|
||||
<svg
|
||||
class="size-4 transition-transform group-hover:translate-x-0.5"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../content.config'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GalleryItemAttribution from './GalleryItemAttribution.vue'
|
||||
|
||||
@@ -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-xs text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<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-xs text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<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 '../../content.config'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
@@ -123,11 +123,11 @@ onUnmounted(() => {
|
||||
<!-- Close button -->
|
||||
<button
|
||||
:aria-label="t('gallery.detail.close', locale)"
|
||||
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:top-8 lg:right-26"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:top-8 lg:right-26"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
|
||||
style="mask: url('/icons/close.svg') center / contain no-repeat"
|
||||
/>
|
||||
</button>
|
||||
@@ -136,7 +136,7 @@ onUnmounted(() => {
|
||||
<div class="relative hidden min-h-0 w-full flex-1 pt-12 lg:flex">
|
||||
<!-- Left: info card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8 text-primary-comfy-ink"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
|
||||
>
|
||||
<div
|
||||
:class="transitioning ? 'opacity-0' : 'opacity-100'"
|
||||
@@ -170,7 +170,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Right: large image -->
|
||||
<div
|
||||
class="border-primary-comfy-yellow rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-4"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 p-4"
|
||||
>
|
||||
<component
|
||||
:is="activeItem.video ? 'video' : 'img'"
|
||||
@@ -197,7 +197,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<!-- Image -->
|
||||
<div
|
||||
class="border-primary-comfy-yellow flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 bg-primary-comfy-ink p-3"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
|
||||
>
|
||||
<component
|
||||
:is="activeItem.video ? 'video' : 'img'"
|
||||
@@ -223,7 +223,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Info card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow w-full rounded-4xl p-6 text-primary-comfy-ink"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
|
||||
>
|
||||
<div
|
||||
:class="transitioning ? 'opacity-0' : 'opacity-100'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../content.config'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { GalleryItem } from '../../content.config'
|
||||
import { visibleGalleryItems as items } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GalleryCard from './GalleryCard.vue'
|
||||
import GalleryDetailModal from './GalleryDetailModal.vue'
|
||||
|
||||
const { items, locale = 'en' } = defineProps<{
|
||||
items: GalleryItem[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const modalIndex = ref(0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
|
||||
|
||||
import type { ResolvedTutorial } from '../../content.config'
|
||||
import type { LearningTutorial } from '../../data/learningTutorials'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
@@ -9,7 +9,7 @@ import { t } from '../../i18n/translations'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { tutorial, locale = 'en' } = defineProps<{
|
||||
tutorial: ResolvedTutorial
|
||||
tutorial: LearningTutorial
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
@@ -39,7 +39,7 @@ onUnmounted(() => {
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
:aria-label="tutorial.title"
|
||||
:aria-label="tutorial.title[locale]"
|
||||
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
|
||||
@click="handleBackdropClick"
|
||||
@keydown="handleKeydown"
|
||||
@@ -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.slug"
|
||||
:key="tutorial.id"
|
||||
: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 }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h2>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ResolvedTutorial } from '../../content.config'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
getTutorialPosterSrc,
|
||||
learningTutorials
|
||||
} from '../../data/learningTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { getTutorialPosterSrc } from '../../utils/tutorial'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import MaskRevealButton from '../common/MaskRevealButton.vue'
|
||||
import TutorialDetailDialog from './TutorialDetailDialog.vue'
|
||||
|
||||
const { tutorials, locale = 'en' } = defineProps<{
|
||||
tutorials: readonly ResolvedTutorial[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeTutorialSlug = ref<string | null>(null)
|
||||
const activeTutorialId = ref<string | null>(null)
|
||||
const activeTutorial = () =>
|
||||
tutorials.find((tutorial) => tutorial.slug === activeTutorialSlug.value)
|
||||
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,15 +31,15 @@ const activeTutorial = () =>
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li
|
||||
v-for="tutorial in tutorials"
|
||||
:key="tutorial.slug"
|
||||
v-for="tutorial in learningTutorials"
|
||||
:key="tutorial.id"
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title}`"
|
||||
@click="activeTutorialSlug = tutorial.slug"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
|
||||
@click="activeTutorialId = tutorial.id"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
@@ -75,7 +74,7 @@ const activeTutorial = () =>
|
||||
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
|
||||
{{ tutorial.title }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<MaskRevealButton
|
||||
v-if="tutorial.href"
|
||||
@@ -104,7 +103,7 @@ const activeTutorial = () =>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ tag }}</Badge>
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -115,7 +114,7 @@ const activeTutorial = () =>
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
@close="activeTutorialSlug = null"
|
||||
@close="activeTutorialId = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { GalleryItem } from '../../content.config'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
@@ -9,14 +9,64 @@ import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryCard from '../gallery/GalleryCard.vue'
|
||||
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
|
||||
|
||||
const { items, locale = 'en' } = defineProps<{
|
||||
items: readonly GalleryItem[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const modelName = 'Grok'
|
||||
const ctaHref = 'https://comfy.org/workflows/model/grok'
|
||||
|
||||
const items: GalleryItem[] = [
|
||||
{
|
||||
id: 'subway-swan',
|
||||
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
|
||||
title: 'Subway Swan',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'milos-little-wonder',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
|
||||
title: 'Milos Little Wonder',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'amber-passage',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
|
||||
title: 'Amber Passage',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats',
|
||||
objectPosition: 'bottom'
|
||||
},
|
||||
{
|
||||
id: 'neon-revenant',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
|
||||
title: 'Neon Revenant',
|
||||
userAlias: 'Eric Solorio',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.instagram.com/enigmatic_e'
|
||||
},
|
||||
{
|
||||
id: 'midnight-umami',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
|
||||
title: 'Midnight Umami',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
}
|
||||
]
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const modalIndex = ref(0)
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('astro:content', () => ({
|
||||
defineCollection: (config: unknown) => config
|
||||
}))
|
||||
vi.mock('astro/loaders', () => ({
|
||||
glob: () => ({})
|
||||
}))
|
||||
|
||||
import { eventsSchema, gallerySchema, tutorialsSchema } from './content.config'
|
||||
|
||||
const validEntry = {
|
||||
order: 1,
|
||||
title: 'Until Our Eye Interlink harajuku',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
|
||||
href: 'https://www.thinkdiffusion.com/studio'
|
||||
}
|
||||
|
||||
describe('gallerySchema', () => {
|
||||
it('accepts a valid entry', () => {
|
||||
const result = gallerySchema.safeParse(validEntry)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an entry missing a required field with a Zod error naming the field', () => {
|
||||
const { title: _omit, ...withoutTitle } = validEntry
|
||||
const result = gallerySchema.safeParse(withoutTitle)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((issue) => issue.path.join('.'))
|
||||
expect(paths).toContain('title')
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults visible to true when omitted', () => {
|
||||
const result = gallerySchema.safeParse(validEntry)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.visible).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an explicit visible: false', () => {
|
||||
const result = gallerySchema.safeParse({ ...validEntry, visible: false })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.visible).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an invalid URL in image/video/href', () => {
|
||||
const result = gallerySchema.safeParse({
|
||||
...validEntry,
|
||||
href: 'not a url'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const validEvent = {
|
||||
order: 1,
|
||||
label: 'Live Stream:',
|
||||
title: 'Zero to Node: Building Your First Workflow',
|
||||
cta: 'Link',
|
||||
href: 'https://example.com/event'
|
||||
}
|
||||
|
||||
describe('eventsSchema', () => {
|
||||
it('accepts a valid entry', () => {
|
||||
const result = eventsSchema.safeParse(validEvent)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an entry missing a required field with a Zod error naming the field', () => {
|
||||
const { title: _omit, ...withoutTitle } = validEvent
|
||||
const result = eventsSchema.safeParse(withoutTitle)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((issue) => issue.path.join('.'))
|
||||
expect(paths).toContain('title')
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts "#" as a placeholder href', () => {
|
||||
const result = eventsSchema.safeParse({ ...validEvent, href: '#' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a truly malformed href that is neither a URL nor "#"', () => {
|
||||
const result = eventsSchema.safeParse({ ...validEvent, href: 'not a url' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const validTutorial = {
|
||||
order: 1,
|
||||
tags: ['Partner Nodes', 'Image To Video'],
|
||||
title: 'Cleanplate Walkthrough',
|
||||
videoSrc:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg'
|
||||
}
|
||||
|
||||
describe('tutorialsSchema', () => {
|
||||
it('accepts a valid entry', () => {
|
||||
const result = tutorialsSchema.safeParse(validTutorial)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an entry missing a required field with a Zod error naming the field', () => {
|
||||
const { title: _omit, ...withoutTitle } = validTutorial
|
||||
const result = tutorialsSchema.safeParse(withoutTitle)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((issue) => issue.path.join('.'))
|
||||
expect(paths).toContain('title')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an invalid videoSrc URL', () => {
|
||||
const result = tutorialsSchema.safeParse({
|
||||
...validTutorial,
|
||||
videoSrc: 'not a url'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { defineCollection } from 'astro:content'
|
||||
import { glob } from 'astro/loaders'
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
export const gallerySchema = z.object({
|
||||
order: z.number().int(),
|
||||
image: z.string().url().optional(),
|
||||
video: z.string().url().optional(),
|
||||
title: z.string(),
|
||||
userAlias: z.string(),
|
||||
teamAlias: z.string(),
|
||||
tool: z.string(),
|
||||
href: z.string().url().optional(),
|
||||
objectPosition: z.string().optional(),
|
||||
objectFit: z.string().optional(),
|
||||
visible: z.boolean().default(true)
|
||||
})
|
||||
|
||||
export type GalleryItem = z.infer<typeof gallerySchema>
|
||||
|
||||
export const eventsSchema = z.object({
|
||||
order: z.number().int(),
|
||||
label: z.string(),
|
||||
title: z.string(),
|
||||
cta: z.string(),
|
||||
href: z.string().url().or(z.literal('#'))
|
||||
})
|
||||
|
||||
export type EventItem = z.infer<typeof eventsSchema>
|
||||
|
||||
export const tutorialsSchema = z.object({
|
||||
order: z.number().int(),
|
||||
tags: z.array(z.string()),
|
||||
title: z.string(),
|
||||
videoSrc: z.string().url(),
|
||||
href: z.string().url().optional(),
|
||||
poster: z.string().url().optional(),
|
||||
posterTime: z.number().optional()
|
||||
})
|
||||
|
||||
export type LearningTutorial = z.infer<typeof tutorialsSchema>
|
||||
export type ResolvedTutorial = LearningTutorial & { slug: string }
|
||||
|
||||
// The default `generateId` lowercases path segments (e.g. `zh-CN/foo` becomes
|
||||
// `zh-cn/foo`), which collides with the BCP-47 locale codes Astro's i18n
|
||||
// config uses elsewhere. Strip the file extension to keep the path — and
|
||||
// therefore the locale prefix — verbatim.
|
||||
const preservePathId = ({ entry }: { entry: string }): string =>
|
||||
entry.replace(/\.[^.]+$/, '')
|
||||
|
||||
const gallery = defineCollection({
|
||||
loader: glob({
|
||||
pattern: '**/*.json',
|
||||
base: './src/content/gallery',
|
||||
generateId: preservePathId
|
||||
}),
|
||||
schema: gallerySchema
|
||||
})
|
||||
|
||||
const events = defineCollection({
|
||||
loader: glob({
|
||||
pattern: '**/*.json',
|
||||
base: './src/content/events',
|
||||
generateId: preservePathId
|
||||
}),
|
||||
schema: eventsSchema
|
||||
})
|
||||
|
||||
const tutorials = defineCollection({
|
||||
loader: glob({
|
||||
pattern: '**/*.md',
|
||||
base: './src/content/tutorials',
|
||||
generateId: preservePathId
|
||||
}),
|
||||
schema: tutorialsSchema
|
||||
})
|
||||
|
||||
export const collections = { gallery, events, tutorials }
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"order": 2,
|
||||
"label": "Event 1",
|
||||
"title": "Lorem ipsum dollar sita met",
|
||||
"cta": "London, UK",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"order": 3,
|
||||
"label": "Event 2",
|
||||
"title": "Lorem ipsum dollar sita met",
|
||||
"cta": "San Francisco",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"order": 1,
|
||||
"label": "Live Stream:",
|
||||
"title": "Zero to Node: Building Your First Workflow",
|
||||
"cta": "Link",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"order": 2,
|
||||
"label": "活动 1",
|
||||
"title": "此处为活动描述的占位文本",
|
||||
"cta": "英国伦敦",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"order": 3,
|
||||
"label": "活动 2",
|
||||
"title": "此处为活动描述的占位文本",
|
||||
"cta": "旧金山",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"order": 1,
|
||||
"label": "直播:",
|
||||
"title": "从零到节点:构建你的第一个工作流",
|
||||
"cta": "链接",
|
||||
"href": "#"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 17,
|
||||
"image": "https://media.comfy.org/website/gallery/gallery.webp",
|
||||
"title": "Amber Astronaut",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"order": 21,
|
||||
"image": "https://media.comfy.org/website/gallery/amber-passage_compressed.jpg",
|
||||
"title": "Amber Passage",
|
||||
"userAlias": "Purz Beats",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.youtube.com/@PurzBeats",
|
||||
"objectPosition": "bottom",
|
||||
"visible": false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 16,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
|
||||
"title": "Animation Reel",
|
||||
"userAlias": "Andidea",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 5,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
|
||||
"title": "Autopoiesis",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/visualfrisson/?hl=en"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 13,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
|
||||
"title": "Cuco - A Love Letter To LA",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "CoffeeVectors",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://vimeo.com/1062859798"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 12,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
|
||||
"title": "DDU-DU DDU-DU",
|
||||
"userAlias": "Purz",
|
||||
"teamAlias": "Andidea",
|
||||
"tool": "Animatediff",
|
||||
"href": "https://vimeo.com/1019924290"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 18,
|
||||
"image": "https://media.comfy.org/website/gallery/desert.webp",
|
||||
"title": "Desert Landing",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 6,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
|
||||
"title": "Eat It - Dance",
|
||||
"userAlias": "Johana Lyu",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.joannalyu.com/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 7,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
|
||||
"title": "Fall",
|
||||
"userAlias": "Nathan Shipley",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 11,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
|
||||
"title": "It's gonna be a good good summer",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "",
|
||||
"tool": "CogvideoX",
|
||||
"href": "https://vimeo.com/1019685900"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 15,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
|
||||
"title": "Goodbye Beijing",
|
||||
"userAlias": "Rui",
|
||||
"teamAlias": "makeitrad",
|
||||
"tool": "Animatediff",
|
||||
"href": "https://x.com/rui40000"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"order": 23,
|
||||
"image": "https://media.comfy.org/website/gallery/midnight_umami_compressed.png",
|
||||
"title": "Midnight Umami",
|
||||
"userAlias": "Purz Beats",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.youtube.com/@PurzBeats",
|
||||
"visible": false
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"order": 20,
|
||||
"video": "https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4",
|
||||
"title": "Milos Little Wonder",
|
||||
"userAlias": "Purz Beats",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.youtube.com/@PurzBeats",
|
||||
"visible": false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 3,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
|
||||
"title": "Neon Nights",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"order": 22,
|
||||
"video": "https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4",
|
||||
"title": "Neon Revenant",
|
||||
"userAlias": "Eric Solorio",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.instagram.com/enigmatic_e",
|
||||
"visible": false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 9,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
|
||||
"title": "Origami world",
|
||||
"userAlias": "Karen X",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/karenxcheng/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 2,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
|
||||
"title": "Origins - Kyrie Irving",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "ThinkDiffusion",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://vimeo.com/1021360563"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 10,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
|
||||
"title": "Shot on InstaX",
|
||||
"userAlias": "Karen X",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/karenxcheng/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 14,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
|
||||
"title": "Show you my garden",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "",
|
||||
"tool": "CogvideoX",
|
||||
"href": "https://vimeo.com/1019685479"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"order": 19,
|
||||
"image": "https://media.comfy.org/website/gallery/subway-swan_compressed.png",
|
||||
"title": "Subway Swan",
|
||||
"userAlias": "Purz Beats",
|
||||
"teamAlias": "Comfy",
|
||||
"tool": "Grok Imagine",
|
||||
"href": "https://www.youtube.com/@PurzBeats",
|
||||
"visible": false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 1,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
|
||||
"title": "Until Our Eye Interlink harajuku",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "ThinkDiffusion",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 8,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
|
||||
"title": "Untitled",
|
||||
"userAlias": "Nathan Shipley",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 4,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
|
||||
"title": "Untitled",
|
||||
"userAlias": "MidJourney man",
|
||||
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 17,
|
||||
"image": "https://media.comfy.org/website/gallery/gallery.webp",
|
||||
"title": "Amber Astronaut",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 16,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
|
||||
"title": "Animation Reel",
|
||||
"userAlias": "Andidea",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 5,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
|
||||
"title": "Autopoiesis",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/visualfrisson/?hl=en"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 13,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
|
||||
"title": "Cuco - A Love Letter To LA",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "CoffeeVectors",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://vimeo.com/1062859798"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 12,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
|
||||
"title": "DDU-DU DDU-DU",
|
||||
"userAlias": "Purz",
|
||||
"teamAlias": "Andidea",
|
||||
"tool": "Animatediff",
|
||||
"href": "https://vimeo.com/1019924290"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 18,
|
||||
"image": "https://media.comfy.org/website/gallery/desert.webp",
|
||||
"title": "Desert Landing",
|
||||
"userAlias": "Yogo",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 6,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
|
||||
"title": "Eat It - Dance",
|
||||
"userAlias": "Johana Lyu",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.joannalyu.com/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 7,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
|
||||
"title": "Fall",
|
||||
"userAlias": "Nathan Shipley",
|
||||
"teamAlias": "Visual Frisson",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 11,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
|
||||
"title": "It's gonna be a good good summer",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "",
|
||||
"tool": "CogvideoX",
|
||||
"href": "https://vimeo.com/1019685900"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 15,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
|
||||
"title": "Goodbye Beijing",
|
||||
"userAlias": "Rui",
|
||||
"teamAlias": "makeitrad",
|
||||
"tool": "Animatediff",
|
||||
"href": "https://x.com/rui40000"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 3,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
|
||||
"title": "Neon Nights",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 9,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
|
||||
"title": "Origami world",
|
||||
"userAlias": "Karen X",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/karenxcheng/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 2,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
|
||||
"title": "Origins - Kyrie Irving",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "ThinkDiffusion",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://vimeo.com/1021360563"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 10,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
|
||||
"title": "Shot on InstaX",
|
||||
"userAlias": "Karen X",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/karenxcheng/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 14,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
|
||||
"title": "Show you my garden",
|
||||
"userAlias": "Paul Trillo",
|
||||
"teamAlias": "",
|
||||
"tool": "CogvideoX",
|
||||
"href": "https://vimeo.com/1019685479"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 1,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
|
||||
"title": "Until Our Eye Interlink harajuku",
|
||||
"userAlias": "ShaneF Motion Design",
|
||||
"teamAlias": "ThinkDiffusion",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 8,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
|
||||
"title": "Untitled",
|
||||
"userAlias": "Nathan Shipley",
|
||||
"teamAlias": "",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"order": 4,
|
||||
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
|
||||
"title": "Untitled",
|
||||
"userAlias": "MidJourney man",
|
||||
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
|
||||
"tool": "ComfyUI",
|
||||
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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/
|
||||
---
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
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/
|
||||
---
|
||||
31
apps/website/src/data/events.ts
Normal file
31
apps/website/src/data/events.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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
|
||||
191
apps/website/src/data/gallery.ts
Normal file
191
apps/website/src/data/gallery.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
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)
|
||||
}
|
||||
84
apps/website/src/data/learningTutorials.ts
Normal file
84
apps/website/src/data/learningTutorials.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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,14 +3,10 @@ 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 items={items} client:load />
|
||||
<GallerySection client:load />
|
||||
<ContactSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -8,20 +8,14 @@ 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 tutorials={tutorials} client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<CallToActionSection
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
|
||||
@@ -3,18 +3,7 @@ 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 { 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)
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -27,7 +16,7 @@ const featuredItems = featuredEntries.map((entry) => entry.data)
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="Grok Imagine output created with ComfyUI"
|
||||
/>
|
||||
<ModelCreationsSection items={featuredItems} client:load />
|
||||
<ModelCreationsSection client:load />
|
||||
<AIModelsSection client:load />
|
||||
<ProductShowcaseSection client:load />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -3,14 +3,10 @@ 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" items={items} client:load />
|
||||
<GallerySection locale="zh-CN" client:load />
|
||||
<ContactSection locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -6,34 +6,15 @@ 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 {
|
||||
getEventsByLocale,
|
||||
getTutorialsByLocale,
|
||||
slugOf
|
||||
} from '../../content/queries'
|
||||
import { learningEvents } from '../../data/events'
|
||||
|
||||
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" tutorials={tutorials} client:visible />
|
||||
<EventsSection
|
||||
locale="zh-CN"
|
||||
headingKey="learning.events.heading"
|
||||
descriptionKey="learning.events.description"
|
||||
notifyLabelKey="learning.events.getNotified"
|
||||
events={events}
|
||||
client:visible
|
||||
/>
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="learning.cta.heading"
|
||||
|
||||
@@ -4,17 +4,6 @@ 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
|
||||
@@ -28,7 +17,7 @@ const featuredItems = featuredEntries.map((entry) => entry.data)
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
|
||||
/>
|
||||
<ModelCreationsSection items={featuredItems} client:load locale="zh-CN" />
|
||||
<ModelCreationsSection client:load locale="zh-CN" />
|
||||
<AIModelsSection client:load locale="zh-CN" />
|
||||
<ProductShowcaseSection client:load locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
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}`
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"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,8 +45,6 @@ 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', '@canvas'] }, () => {
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
@@ -12,39 +12,27 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show missing node pack card with guidance', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
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 }) => {
|
||||
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 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 ({
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -55,72 +43,66 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByRole('button', {
|
||||
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
|
||||
})
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should locate missing node from the row label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
|
||||
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
|
||||
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBeforeLocate)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /collapse/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should toggle grouped pack nodes from chevron and title', async ({
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_same_pack'
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
const packTitle = missingNodeCard.getByRole('button', {
|
||||
name: 'test-missing-node-pack'
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const locateButton = missingNodeCard.getByRole('button', {
|
||||
name: /locate/i
|
||||
})
|
||||
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()
|
||||
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.
|
||||
})
|
||||
})
|
||||
|
||||
@@ -425,56 +425,6 @@ 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
|
||||
}) => {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
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,15 +421,6 @@ 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.12",
|
||||
"version": "1.46.11",
|
||||
"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="#F2FF59"/>
|
||||
<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"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<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"/>
|
||||
<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"/>
|
||||
</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,7 +14,6 @@
|
||||
"UpscaleModelLoader": 629,
|
||||
"UNETLoader": 606,
|
||||
"VAELoader": 604,
|
||||
"PreviewAny": 528,
|
||||
"ShowText|pysssss": 527.5526981023964,
|
||||
"ImageUpscaleWithModel": 523,
|
||||
"ControlNetApplyAdvanced": 513,
|
||||
@@ -25,12 +24,10 @@
|
||||
"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,
|
||||
@@ -55,7 +52,6 @@
|
||||
"CLIPLoader": 202,
|
||||
"GeminiNode": 202,
|
||||
"KSampler (Efficient)": 194.01083622636423,
|
||||
"RemoveBackground": 187,
|
||||
"ImageRemoveBackground+": 186,
|
||||
"IPAdapterModelLoader": 184,
|
||||
"PrimitiveInt": 183,
|
||||
@@ -63,9 +59,7 @@
|
||||
"LoadVideo": 179,
|
||||
"Text Concatenate (JPS)": 175.98154639522735,
|
||||
"PrimitiveNode": 175,
|
||||
"PrimitiveStringMultiline": 166,
|
||||
"Text Multiline": 165,
|
||||
"GetImageSize": 164,
|
||||
"Text Multiline": 163.04749064680308,
|
||||
"GetImageSize+": 163,
|
||||
"ImageScaleToTotalPixels": 157,
|
||||
"String Literal": 150.11343489837878,
|
||||
@@ -74,14 +68,15 @@
|
||||
"DownloadAndLoadFlorence2Model": 144,
|
||||
"LoadImageOutput": 143,
|
||||
"IPAdapterUnifiedLoader": 141,
|
||||
"BatchImagesNode": 134,
|
||||
"FluxGuidance": 133,
|
||||
"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,
|
||||
@@ -107,7 +102,6 @@
|
||||
"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,
|
||||
@@ -153,6 +147,7 @@
|
||||
"Image Resize": 63.494455492264656,
|
||||
"Automatic CFG": 63.494455492264656,
|
||||
"Canny": 63,
|
||||
"StringConcatenate": 63,
|
||||
"DepthAnything_V2": 61,
|
||||
"ImageCrop+": 60,
|
||||
"ModelSamplingSD3": 59,
|
||||
@@ -204,7 +199,6 @@
|
||||
"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,
|
||||
@@ -395,6 +389,7 @@
|
||||
"SD_4XUpscale_Conditioning": 21,
|
||||
"UltimateSDUpscaleCustomSample": 21,
|
||||
"StyleModelLoader": 21,
|
||||
"ResizeAndPadImage": 21,
|
||||
"Text Random Prompt": 20.77287741413597,
|
||||
"INPAINT_VAEEncodeInpaintConditioning": 20.77287741413597,
|
||||
"BrushNet": 20.77287741413597,
|
||||
|
||||
@@ -71,11 +71,12 @@ 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'],
|
||||
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
|
||||
emits: ['locate-node', 'open-manager-info']
|
||||
}
|
||||
}))
|
||||
@@ -121,6 +122,7 @@ function makePackGroups(count = 2): MissingPackGroup[] {
|
||||
function renderCard(
|
||||
props: Partial<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}> = {}
|
||||
) {
|
||||
@@ -128,6 +130,7 @@ function renderCard(
|
||||
const result = render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
...props
|
||||
},
|
||||
@@ -166,10 +169,12 @@ describe('MissingNodeCard', () => {
|
||||
|
||||
it('passes props correctly to MissingPackGroupRow children', () => {
|
||||
renderCard({
|
||||
showInfoButton: true
|
||||
showInfoButton: true,
|
||||
showNodeIdBadge: 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -251,6 +256,7 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onLocateNode
|
||||
},
|
||||
@@ -273,6 +279,7 @@ describe('MissingNodeCard', () => {
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onOpenManagerInfo
|
||||
},
|
||||
|
||||
@@ -56,29 +56,27 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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="secondary"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
@@ -107,8 +105,9 @@ 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, missingPackGroups } = defineProps<{
|
||||
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}>()
|
||||
|
||||
|
||||
@@ -61,16 +61,16 @@ const i18n = createI18n({
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
install: 'Install',
|
||||
loading: 'Loading',
|
||||
search: 'Search'
|
||||
loading: 'Loading'
|
||||
},
|
||||
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,6 +100,7 @@ function renderRow(
|
||||
props: Partial<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
@@ -109,6 +110,7 @@ function renderRow(
|
||||
props: {
|
||||
group: makeGroup(),
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
onLocateNode,
|
||||
onOpenManagerInfo,
|
||||
...props
|
||||
@@ -116,6 +118,7 @@ function renderRow(
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
stubs: {
|
||||
TransitionCollapse: { template: '<div><slot /></div>' },
|
||||
DotSpinner: {
|
||||
template: '<span role="status" aria-label="loading" />'
|
||||
}
|
||||
@@ -153,22 +156,9 @@ 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', () => {
|
||||
@@ -181,29 +171,38 @@ describe('MissingPackGroupRow', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type List', () => {
|
||||
it('hides multiple nodeTypes behind the expand control by default', () => {
|
||||
describe('Expand / Collapse', () => {
|
||||
it('starts collapsed', () => {
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unknown pack nodeTypes by default', () => {
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Collapse' })
|
||||
).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('renders all nodeTypes after expanding', async () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type List', () => {
|
||||
async function expand(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
}
|
||||
|
||||
it('renders all nodeTypes when expanded', async () => {
|
||||
const { user } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
@@ -213,87 +212,40 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
await expand(user)
|
||||
expect(screen.getByText('NodeA')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeB')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeC')).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('shows nodeId badge when showNodeIdBadge is true', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
expect(screen.getByText('#10')).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('hides nodeId badge when showNodeIdBadge is false', async () => {
|
||||
const { user } = renderRow({ showNodeIdBadge: false })
|
||||
await expand(user)
|
||||
expect(screen.queryByText('#10')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
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' }))
|
||||
|
||||
it('emits locateNode when Locate button is clicked', async () => {
|
||||
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
|
||||
)
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
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({
|
||||
it('does not show Locate for nodeType without nodeId', async () => {
|
||||
const { user } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
|
||||
})
|
||||
})
|
||||
await expand(user)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Locate node on canvas' })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -301,6 +253,7 @@ 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 },
|
||||
@@ -308,7 +261,7 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
await expand(user)
|
||||
expect(screen.getByText('WithId')).toBeInTheDocument()
|
||||
expect(screen.getByText('WithoutId')).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -321,25 +274,21 @@ describe('MissingPackGroupRow', () => {
|
||||
it('hides install UI when shouldShowManagerButtons is false', () => {
|
||||
mockShouldShowManagerButtons.value = false
|
||||
renderRow()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides install UI when packId is null', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Install' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Search when packId exists but pack not in registry', () => {
|
||||
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = []
|
||||
renderRow()
|
||||
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Installed" state when pack is installed', () => {
|
||||
@@ -363,9 +312,7 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
renderRow()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Install' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Install node pack')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls installAllPacks when Install button is clicked', async () => {
|
||||
@@ -373,7 +320,9 @@ describe('MissingPackGroupRow', () => {
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Install' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Install node pack/ })
|
||||
)
|
||||
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -420,7 +369,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,221 +1,187 @@
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
<Button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="missing-node-pack-expand"
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
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 }
|
||||
)
|
||||
"
|
||||
: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
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
<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'
|
||||
)
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
{{ packDisplayName }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="primaryLocatableNodeType"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
packTextButtonClass,
|
||||
isUnknownPack
|
||||
? 'text-warning-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
{{ packDisplayName }}
|
||||
</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-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
v-if="
|
||||
showNodeIdBadge &&
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null
|
||||
"
|
||||
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"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ packDisplayName }}
|
||||
</span>
|
||||
#{{ nodeType.nodeId }}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getLabel(nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
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 ?? '')"
|
||||
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
<i class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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"
|
||||
>
|
||||
<div
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<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 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
|
||||
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 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'
|
||||
)
|
||||
<!-- 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"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1 rounded-lg"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ref, computed } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
@@ -227,9 +193,10 @@ 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 } = defineProps<{
|
||||
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -238,10 +205,6 @@ 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()
|
||||
@@ -256,73 +219,17 @@ 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 (!isPackInstalled.value) {
|
||||
if (!comfyManagerStore.isPackInstalled(group.packId)) {
|
||||
void installAllPacks()
|
||||
}
|
||||
}
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpand() {
|
||||
expandedOverride.value = !expanded.value
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
function getKey(nodeType: MissingNodeType): string {
|
||||
@@ -334,14 +241,10 @@ 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 (!isLocatableNodeType(nodeType)) return
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
if (typeof nodeType === 'string') return
|
||||
if (nodeType.nodeId != null) {
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
<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"
|
||||
|
||||
@@ -20,7 +20,13 @@
|
||||
</template>
|
||||
<template #end>
|
||||
<div
|
||||
class="flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-row overflow-hidden transition-all duration-200 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2',
|
||||
!props.toolButtonsAlwaysVisible &&
|
||||
'motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="tool-buttons" />
|
||||
</div>
|
||||
@@ -45,6 +51,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
class?: string
|
||||
toolButtonsAlwaysVisible?: boolean
|
||||
}>()
|
||||
const sidebarPt = {
|
||||
start: 'min-w-0 flex-1 overflow-hidden'
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import AssetHoverPreview from './AssetHoverPreview.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
// An empty `tags` array yields no model type, so the node-preview section stays
|
||||
// hidden — keeping the component free of the model-to-node store and the heavy
|
||||
// NodePreview render for these presentational assertions.
|
||||
const baseAsset: AssetItem = {
|
||||
id: 'asset-1',
|
||||
name: 'mymodel.safetensors',
|
||||
tags: []
|
||||
}
|
||||
|
||||
function renderPreview(asset: AssetItem) {
|
||||
return render(AssetHoverPreview, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} }
|
||||
},
|
||||
props: { asset }
|
||||
})
|
||||
}
|
||||
|
||||
describe('AssetHoverPreview', () => {
|
||||
it('shows the description section when a description is present', () => {
|
||||
renderPreview({
|
||||
...baseAsset,
|
||||
user_metadata: { user_description: 'A cutting-edge model.' }
|
||||
})
|
||||
expect(screen.getByText('Description')).toBeInTheDocument()
|
||||
expect(screen.getByText('A cutting-edge model.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the description section when the description is empty', () => {
|
||||
renderPreview({ ...baseAsset, user_metadata: { user_description: '' } })
|
||||
expect(screen.queryByText('Description')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders trigger words as chips under a labelled section', () => {
|
||||
renderPreview({
|
||||
...baseAsset,
|
||||
metadata: { trained_words: ['cat', 'digital art'] }
|
||||
})
|
||||
expect(screen.getByText('Trigger words')).toBeInTheDocument()
|
||||
expect(screen.getByText('cat')).toBeInTheDocument()
|
||||
expect(screen.getByText('digital art')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the trigger words section when there are none', () => {
|
||||
renderPreview(baseAsset)
|
||||
expect(screen.queryByText('Trigger words')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-96 flex-col gap-2 overflow-hidden rounded-2xl border border-border-default bg-comfy-menu-bg p-4 text-sm text-base-foreground shadow-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex w-full items-start gap-2 pb-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col items-start gap-2">
|
||||
<div
|
||||
class="flex w-full flex-col gap-1.5 pr-2 leading-tight wrap-break-word"
|
||||
>
|
||||
<span class="font-medium">{{ displayName }}</span>
|
||||
<span
|
||||
v-if="filename"
|
||||
class="font-normal break-all text-muted-foreground"
|
||||
>
|
||||
{{ filename }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="baseModels.length || sourceUrl"
|
||||
class="flex w-full flex-wrap items-start gap-2 pb-1"
|
||||
>
|
||||
<span
|
||||
v-for="baseModel in baseModels"
|
||||
:key="baseModel"
|
||||
class="inline-flex h-6 max-w-full items-center rounded-full bg-secondary-background px-2 py-1 text-xs text-base-foreground"
|
||||
>
|
||||
<span class="truncate">{{ baseModel }}</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="sourceUrl"
|
||||
v-tooltip.bottom="$t('cloudModelLibrary.preview.openUrl')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 gap-1 rounded-full px-2 font-normal text-base-foreground"
|
||||
:aria-label="$t('cloudModelLibrary.preview.openUrl')"
|
||||
@click="openSourceUrl"
|
||||
>
|
||||
{{ $t('cloudModelLibrary.preview.url') }}
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="relative size-27 shrink-0 overflow-hidden rounded-sm bg-muted-background"
|
||||
>
|
||||
<template v-if="thumbnail">
|
||||
<Skeleton v-if="!thumbnailLoaded" class="absolute inset-0" />
|
||||
<img
|
||||
:src="thumbnail.src"
|
||||
:alt="displayName"
|
||||
class="size-full object-cover transition-opacity duration-150"
|
||||
:class="thumbnailLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
@load="thumbnailLoaded = true"
|
||||
@error="onMediaError"
|
||||
/>
|
||||
</template>
|
||||
<CategoryPlaceholder v-else :category="placeholderCategory" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider: header / description -->
|
||||
<div v-if="description" class="-mx-4 border-t border-border-default" />
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="description" class="flex w-full flex-col gap-2 py-2">
|
||||
<span
|
||||
class="text-xs font-bold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{{ $t('cloudModelLibrary.preview.description') }}
|
||||
</span>
|
||||
<p
|
||||
class="max-h-24 scrollbar-thin overflow-y-auto wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Trigger words -->
|
||||
<div v-if="triggerPhrases.length" class="flex w-full flex-col gap-2 pb-2">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="flex-1 text-xs font-bold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{{ $t('cloudModelLibrary.preview.triggerWords') }}
|
||||
</span>
|
||||
<Button
|
||||
v-tooltip.top="$t('g.copyAll')"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="rounded-lg"
|
||||
:aria-label="$t('g.copyAll')"
|
||||
@click="copyText(triggerPhrases.join(', '))"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="phrase in triggerPhrases"
|
||||
:key="phrase"
|
||||
v-tooltip.bottom="
|
||||
copiedPhrase === phrase ? $t('g.copied') : $t('g.copyToClipboard')
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-6 rounded-full px-2 font-normal text-base-foreground"
|
||||
@click="copyTriggerPhrase(phrase, $event)"
|
||||
>
|
||||
{{ truncatePhrase(phrase) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider: metadata / node preview -->
|
||||
<div v-if="previewNodeDef" class="-mx-4 border-t border-border-default" />
|
||||
|
||||
<!-- Node preview -->
|
||||
<div v-if="previewNodeDef" class="flex w-full flex-col gap-2">
|
||||
<span
|
||||
class="mt-2 text-xs font-bold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{{ $t('cloudModelLibrary.preview.nodePreview') }}
|
||||
</span>
|
||||
<div class="flex w-full justify-center py-2.5">
|
||||
<div
|
||||
ref="previewContainerRef"
|
||||
class="overflow-hidden"
|
||||
:style="{ width: `${NODE_PREVIEW_WIDTH_PX}px` }"
|
||||
>
|
||||
<div
|
||||
ref="previewWrapperRef"
|
||||
class="origin-top-left"
|
||||
:style="{ transform: `scale(${nodePreviewScale})` }"
|
||||
>
|
||||
<LGraphNodePreview :node-def="previewNodeDef" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import CategoryPlaceholder from '@/components/sidebar/tabs/cloudModelLibrary/CategoryPlaceholder.vue'
|
||||
import { formatRowDisplayName } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { placeholderCategoryForAsset } from '@/composables/sidebarTabs/useCategoryPlaceholder'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
const { asset } = defineProps<{ asset: AssetItem }>()
|
||||
|
||||
const rawDisplayName = computed(() => getAssetDisplayName(asset))
|
||||
const displayName = computed(() => formatRowDisplayName(rawDisplayName.value))
|
||||
const filename = computed(() => {
|
||||
const value = getAssetFilename(asset)
|
||||
return value && value !== rawDisplayName.value ? value : ''
|
||||
})
|
||||
|
||||
const baseModels = computed(() => getAssetBaseModels(asset))
|
||||
const description = computed(() => getAssetUserDescription(asset))
|
||||
const triggerPhrases = computed(() => getAssetTriggerPhrases(asset))
|
||||
|
||||
const nativePreviewUrl = computed(
|
||||
() => asset.preview_url ?? asset.thumbnail_url ?? ''
|
||||
)
|
||||
const nativeErrored = ref(false)
|
||||
watch(nativePreviewUrl, () => {
|
||||
nativeErrored.value = false
|
||||
})
|
||||
const thumbnail = computed(() =>
|
||||
nativePreviewUrl.value && !nativeErrored.value
|
||||
? { src: nativePreviewUrl.value }
|
||||
: null
|
||||
)
|
||||
const thumbnailLoaded = ref(false)
|
||||
watch(
|
||||
() => thumbnail.value?.src,
|
||||
() => {
|
||||
thumbnailLoaded.value = false
|
||||
}
|
||||
)
|
||||
const placeholderCategory = computed(() => placeholderCategoryForAsset(asset))
|
||||
function onMediaError() {
|
||||
nativeErrored.value = true
|
||||
}
|
||||
|
||||
const sourceUrl = computed(() => getAssetSourceUrl(asset))
|
||||
function openSourceUrl() {
|
||||
if (!sourceUrl.value) return
|
||||
window.open(sourceUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// The plain Load node for the asset's category — surfaced as a live preview so
|
||||
// the user sees the result before inserting.
|
||||
const previewNodeDef = computed(() => {
|
||||
const category = getAssetModelType(asset)
|
||||
if (!category) return null
|
||||
return useModelToNodeStore().getNodeProvider(category)?.nodeDef ?? null
|
||||
})
|
||||
|
||||
// LGraphNodePreview renders at a fixed 350px; scale it to the Figma node-preview
|
||||
// width and compensate the container height so the CSS transform doesn't leave
|
||||
// empty space below the node.
|
||||
const NODE_PREVIEW_WIDTH_PX = 268
|
||||
const NODE_BASE_WIDTH_PX = 350
|
||||
const nodePreviewScale = NODE_PREVIEW_WIDTH_PX / NODE_BASE_WIDTH_PX
|
||||
const previewContainerRef = ref<HTMLElement>()
|
||||
const previewWrapperRef = ref<HTMLElement>()
|
||||
useResizeObserver(previewWrapperRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry && previewContainerRef.value) {
|
||||
previewContainerRef.value.style.height = `${entry.contentRect.height * nodePreviewScale}px`
|
||||
}
|
||||
})
|
||||
|
||||
async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
// Tracks the trigger word most recently copied so its tooltip can flip to
|
||||
// "Copied" as confirmation.
|
||||
const copiedPhrase = ref<string | null>(null)
|
||||
let copiedResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const COPIED_FEEDBACK_MS = 1500
|
||||
|
||||
async function copyTriggerPhrase(phrase: string, event: MouseEvent) {
|
||||
const target = event.currentTarget
|
||||
await copyText(phrase)
|
||||
copiedPhrase.value = phrase
|
||||
// PrimeVue hides the tooltip on click and doesn't refresh a visible tooltip's
|
||||
// text, so re-trigger it to surface the updated "Copied" label in place.
|
||||
await nextTick()
|
||||
if (target instanceof HTMLElement)
|
||||
target.dispatchEvent(new MouseEvent('mouseenter'))
|
||||
if (copiedResetTimer) clearTimeout(copiedResetTimer)
|
||||
copiedResetTimer = setTimeout(() => {
|
||||
copiedPhrase.value = null
|
||||
copiedResetTimer = null
|
||||
}, COPIED_FEEDBACK_MS)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copiedResetTimer) clearTimeout(copiedResetTimer)
|
||||
})
|
||||
|
||||
const TRIGGER_PHRASE_MAX_LENGTH = 20
|
||||
function truncatePhrase(phrase: string): string {
|
||||
return phrase.length > TRIGGER_PHRASE_MAX_LENGTH
|
||||
? `${phrase.slice(0, TRIGGER_PHRASE_MAX_LENGTH)}…`
|
||||
: phrase
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { placeholderGradientForCategory } from '@/composables/sidebarTabs/useCategoryPlaceholder'
|
||||
|
||||
const { category } = defineProps<{ category: string }>()
|
||||
const background = computed(() => placeholderGradientForCategory(category))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="size-full" :style="{ background }" />
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user