mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-05 20:54:56 +00:00
Compare commits
16 Commits
austin/upl
...
feat/model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c607b98130 | ||
|
|
9e9d08febf | ||
|
|
411de899a5 | ||
|
|
4db6d81804 | ||
|
|
58ad02db4e | ||
|
|
e0c9c96052 | ||
|
|
0b2c250c85 | ||
|
|
676226e3ad | ||
|
|
b2176e441a | ||
|
|
f15ef81f03 | ||
|
|
a98d75dbee | ||
|
|
36c7fbfee0 | ||
|
|
3417fbb7e5 | ||
|
|
6ed81e05db | ||
|
|
08354d74a3 | ||
|
|
5ddf5faef3 |
@@ -58,7 +58,7 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
runway: { name: 'Runway', slug: 'runway' },
|
||||
vidu: { name: 'Vidu', slug: 'vidu' },
|
||||
bfl: { name: 'Flux (API)', slug: 'flux-api' },
|
||||
grok: { name: 'Grok Image', slug: 'grok-image' },
|
||||
grok: { name: 'Grok Imagine', slug: 'grok-imagine' },
|
||||
stability: { name: 'Stability AI', slug: 'stability-ai' },
|
||||
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
@@ -86,6 +86,20 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
|
||||
}
|
||||
|
||||
// Stub entries that exist only to issue 301 redirects from old slugs to
|
||||
// their new canonical slugs. Keeps renames reproducible across regenerations.
|
||||
const LEGACY_SLUG_REDIRECTS: OutputModel[] = [
|
||||
{
|
||||
slug: 'grok-image',
|
||||
canonicalSlug: 'grok-imagine',
|
||||
name: 'Grok Image',
|
||||
displayName: 'Grok Image',
|
||||
directory: 'partner_nodes',
|
||||
huggingFaceUrl: '',
|
||||
workflowCount: 0
|
||||
}
|
||||
]
|
||||
|
||||
function stripExt(name: string): string {
|
||||
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
|
||||
}
|
||||
@@ -299,7 +313,8 @@ function run(): void {
|
||||
throw new Error(
|
||||
`Failed to parse ${file}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -367,7 +382,7 @@ function run(): void {
|
||||
displayName: m.name
|
||||
}))
|
||||
|
||||
const combined = [...apiOutput, ...output]
|
||||
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
|
||||
|
||||
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
|
||||
process.stdout.write(
|
||||
|
||||
17
apps/website/src/components/common/Badge.vue
Normal file
17
apps/website/src/components/common/Badge.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { BadgeVariants } from './badge.variants'
|
||||
import { badgeVariants } from './badge.variants'
|
||||
|
||||
const { variant, class: className } = defineProps<{
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="cn(badgeVariants({ variant }), className)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
53
apps/website/src/components/common/CallToActionSection.vue
Normal file
53
apps/website/src/components/common/CallToActionSection.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
primaryLabelKey,
|
||||
primaryHref,
|
||||
secondaryLabelKey,
|
||||
secondaryHref
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
primaryLabelKey: TranslationKey
|
||||
primaryHref?: string
|
||||
secondaryLabelKey?: TranslationKey
|
||||
secondaryHref?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<BrandButton
|
||||
:href="primaryHref"
|
||||
variant="solid"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(primaryLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
v-if="secondaryLabelKey"
|
||||
:href="secondaryHref"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(secondaryLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
102
apps/website/src/components/common/EventsSection.vue
Normal file
102
apps/website/src/components/common/EventsSection.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
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,
|
||||
descriptionKey,
|
||||
notifyLabelKey,
|
||||
notifyHref,
|
||||
events
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
descriptionKey: TranslationKey
|
||||
notifyLabelKey: TranslationKey
|
||||
notifyHref?: string
|
||||
events: readonly EventItem[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-12">
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-4xl px-6 py-12 lg:px-16 lg:py-20"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
<div>
|
||||
<BrandButton
|
||||
:href="notifyHref"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
>
|
||||
{{ t(notifyLabelKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray flex-1 text-sm">
|
||||
{{ event.title[locale] }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
|
||||
>
|
||||
{{ event.cta[locale] }}
|
||||
<svg
|
||||
class="size-4 transition-transform group-hover:translate-x-0.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
165
apps/website/src/components/common/MaskRevealButton.stories.ts
Normal file
165
apps/website/src/components/common/MaskRevealButton.stories.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import MaskRevealButton from './MaskRevealButton.vue'
|
||||
|
||||
const meta: Meta<typeof MaskRevealButton> = {
|
||||
title: 'Website/Common/MaskRevealButton',
|
||||
component: MaskRevealButton,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['right', 'left']
|
||||
},
|
||||
hideLabel: { control: 'boolean' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Read More</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Go Back</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Let's Collaborate</MaskRevealButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<MaskRevealButton v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LabelVisible: Story = {
|
||||
args: { href: '#', hideLabel: false },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template:
|
||||
'<MaskRevealButton v-bind="args">Always Visible</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Unavailable</MaskRevealButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { MaskRevealButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" variant="solid" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="solid" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="solid" size="lg">Large</MaskRevealButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" variant="ghost" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="ghost" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" variant="ghost" size="lg">Large</MaskRevealButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Icon Left</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<MaskRevealButton href="#" iconPosition="left" size="sm">Small</MaskRevealButton>
|
||||
<MaskRevealButton href="#" iconPosition="left" size="md">Medium</MaskRevealButton>
|
||||
<MaskRevealButton href="#" iconPosition="left" size="lg">Large</MaskRevealButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
82
apps/website/src/components/common/MaskRevealButton.vue
Normal file
82
apps/website/src/components/common/MaskRevealButton.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { MaskRevealButtonVariants } from './maskRevealButton.variants'
|
||||
import {
|
||||
maskRevealButtonBadgeVariants,
|
||||
maskRevealButtonVariants,
|
||||
maskRevealLabelVariants
|
||||
} from './maskRevealButton.variants'
|
||||
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
type = 'button',
|
||||
disabled,
|
||||
ariaLabel,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
hideLabel = true,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
variant?: MaskRevealButtonVariants['variant']
|
||||
size?: MaskRevealButtonVariants['size']
|
||||
iconPosition?: MaskRevealButtonVariants['iconPosition']
|
||||
hideLabel?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href="href || undefined"
|
||||
:target="href ? target : undefined"
|
||||
:rel="href ? rel : undefined"
|
||||
:type="!href ? type : undefined"
|
||||
:disabled="!href ? disabled : undefined"
|
||||
:aria-label="ariaLabel"
|
||||
:class="
|
||||
cn(maskRevealButtonVariants({ variant, size, iconPosition }), customClass)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:data-icon-position="iconPosition ?? 'right'"
|
||||
:data-hidden="hideLabel ? 'true' : 'false'"
|
||||
:class="maskRevealLabelVariants()"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="maskRevealButtonBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M7 17 17 7" />
|
||||
<path d="M7 7h10v10" />
|
||||
</svg>
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
165
apps/website/src/components/common/PillButton.stories.ts
Normal file
165
apps/website/src/components/common/PillButton.stories.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import PillButton from './PillButton.vue'
|
||||
|
||||
const meta: Meta<typeof PillButton> = {
|
||||
title: 'Website/Common/PillButton',
|
||||
component: PillButton,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['right', 'left']
|
||||
},
|
||||
hideLabel: { control: 'boolean' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AsAnchor: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const AsButton: Story = {
|
||||
args: { type: 'button' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Submit</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Read More</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<PillButton v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</PillButton>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Go Back</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const RevealLabelOnHover: Story = {
|
||||
args: { href: '#', hideLabel: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Unavailable</PillButton>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { PillButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
|
||||
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
|
||||
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
|
||||
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
|
||||
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
84
apps/website/src/components/common/PillButton.vue
Normal file
84
apps/website/src/components/common/PillButton.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { PillButtonVariants } from './pillButton.variants'
|
||||
import {
|
||||
pillButtonBadgeVariants,
|
||||
pillButtonVariants
|
||||
} from './pillButton.variants'
|
||||
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
type = 'button',
|
||||
disabled,
|
||||
ariaLabel,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
hideLabel = false,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
variant?: PillButtonVariants['variant']
|
||||
size?: PillButtonVariants['size']
|
||||
iconPosition?: PillButtonVariants['iconPosition']
|
||||
hideLabel?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href="href || undefined"
|
||||
:target="href ? target : undefined"
|
||||
:rel="href ? rel : undefined"
|
||||
:type="!href ? type : undefined"
|
||||
:disabled="!href ? disabled : undefined"
|
||||
:aria-label="ariaLabel"
|
||||
:class="
|
||||
cn(pillButtonVariants({ variant, size, iconPosition }), customClass)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'relative leading-none transition-all duration-500',
|
||||
hideLabel && 'opacity-0 group-hover:opacity-100'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="pillButtonBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M7 17 17 7" />
|
||||
<path d="M7 7h10v10" />
|
||||
</svg>
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
@@ -43,6 +43,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{
|
||||
title: t('footer.resources', locale),
|
||||
links: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{
|
||||
label: t('footer.blog', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
@@ -52,6 +52,7 @@ const navLinks: NavLink[] = [
|
||||
{
|
||||
label: t('nav.resources', locale),
|
||||
items: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
17
apps/website/src/components/common/badge.variants.ts
Normal file
17
apps/website/src/components/common/badge.variants.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const badgeVariants = cva({
|
||||
base: 'text-primary-warm-gray focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-4 py-1 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
})
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
110
apps/website/src/components/common/maskRevealButton.variants.ts
Normal file
110
apps/website/src/components/common/maskRevealButton.variants.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const maskRevealButtonVariants = cva({
|
||||
base: 'group relative uppercase inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
ghost: 'text-primary-comfy-yellow bg-transparent'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-10 text-xs',
|
||||
md: 'h-12 text-sm',
|
||||
lg: 'h-14 text-base'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{ size: 'sm', iconPosition: 'right', class: 'ps-12 pe-4' },
|
||||
{ size: 'md', iconPosition: 'right', class: 'ps-14 pe-6' },
|
||||
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
|
||||
{ size: 'sm', iconPosition: 'left', class: 'ps-4 pe-12' },
|
||||
{ size: 'md', iconPosition: 'left', class: 'ps-6 pe-14' },
|
||||
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const maskRevealButtonBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
md: 'size-10',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const maskRevealLabelVariants = cva({
|
||||
base: [
|
||||
'relative inline-block align-baseline',
|
||||
'[will-change:mask-size,-webkit-mask-size]',
|
||||
'[mask-image:linear-gradient(black,black)] [-webkit-mask-image:linear-gradient(black,black)]',
|
||||
'mask-no-repeat [-webkit-mask-repeat:no-repeat]',
|
||||
'transition-[mask-size,-webkit-mask-size] duration-500 ease-in-out',
|
||||
'data-[icon-position=right]:[mask-position:100%_0] data-[icon-position=right]:[-webkit-mask-position:100%_0]',
|
||||
'data-[icon-position=left]:[mask-position:0_0] data-[icon-position=left]:[-webkit-mask-position:0_0]',
|
||||
'data-[hidden=true]:[mask-size:0%_100%] data-[hidden=true]:[-webkit-mask-size:0%_100%]',
|
||||
'data-[hidden=false]:[mask-size:100%_100%] data-[hidden=false]:[-webkit-mask-size:100%_100%]',
|
||||
'group-hover:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
|
||||
'group-focus-visible:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
|
||||
].join(' ')
|
||||
})
|
||||
|
||||
export type MaskRevealButtonVariants = VariantProps<
|
||||
typeof maskRevealButtonVariants
|
||||
>
|
||||
116
apps/website/src/components/common/pillButton.variants.ts
Normal file
116
apps/website/src/components/common/pillButton.variants.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const pillButtonVariants = cva({
|
||||
base: 'group relative inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
ghost: 'text-primary-comfy-yellow bg-transparent'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-10 text-xs',
|
||||
md: 'h-12 text-sm',
|
||||
lg: 'h-14 text-base'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'right',
|
||||
class: 'ps-4 pe-12 hover:ps-12 hover:pe-4'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'right',
|
||||
class: 'ps-6 pe-14 hover:ps-14 hover:pe-6'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'ps-8 pe-16 hover:ps-16 hover:pe-8'
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'left',
|
||||
class: 'ps-12 pe-4 hover:ps-4 hover:pe-12'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'left',
|
||||
class: 'ps-14 pe-6 hover:ps-6 hover:pe-14'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'ps-16 pe-8 hover:ps-8 hover:pe-16'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const pillButtonBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
md: 'size-10',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-44px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'md',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export type PillButtonVariants = VariantProps<typeof pillButtonVariants>
|
||||
@@ -7,12 +7,16 @@ const {
|
||||
item,
|
||||
locale = 'en',
|
||||
aspect = 'var(--aspect-ratio-gallery-card)',
|
||||
mobile = false
|
||||
mobile = false,
|
||||
objectPosition = 'center',
|
||||
objectFit = 'cover'
|
||||
} = defineProps<{
|
||||
item: GalleryItem
|
||||
locale?: Locale
|
||||
aspect?: string
|
||||
mobile?: boolean
|
||||
objectPosition?: string
|
||||
objectFit?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
@@ -31,13 +35,15 @@ defineEmits<{ click: [] }>()
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
class="size-full transition-transform duration-300 group-hover:scale-105"
|
||||
:style="{ objectPosition, objectFit }"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="item.image"
|
||||
:alt="item.title"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
class="size-full transition-transform duration-300 group-hover:scale-105"
|
||||
:style="{ objectPosition, objectFit }"
|
||||
/>
|
||||
<!-- Desktop hover overlay -->
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const tags = ['Seadance 2.0', 'Image To Video']
|
||||
const demoVideoSrc =
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4'
|
||||
const demoVideoPoster =
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ t('learning.featured.title', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm lg:text-base">
|
||||
{{ t('learning.featured.author', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('learning.featured.description', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<BrandButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="uppercase"
|
||||
href="https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<ul class="mt-2 flex flex-wrap gap-3">
|
||||
<li v-for="tag in tags" :key="tag">
|
||||
<Badge variant="subtle">{{ tag }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
|
||||
<VideoPlayer
|
||||
:locale
|
||||
:src="demoVideoSrc"
|
||||
:poster="demoVideoPoster"
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
23
apps/website/src/components/learning/HeroSection.vue
Normal file
23
apps/website/src/components/learning/HeroSection.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
|
||||
>
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
>{{ t('learning.heroTitle.after', locale) }}
|
||||
<br />
|
||||
{{ t('learning.heroTitle.line2', locale) }}
|
||||
</h1>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { LearningTutorial } from '../../data/learningTutorials'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { tutorial, locale = 'en' } = defineProps<{
|
||||
tutorial: LearningTutorial
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
|
||||
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
|
||||
|
||||
const playFromStart = () => {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
video.currentTime = 0
|
||||
void video.play().catch(() => {})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => tutorial.id,
|
||||
() => {
|
||||
playFromStart()
|
||||
}
|
||||
)
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) emit('close')
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
lockScroll()
|
||||
dialogRef.value?.showModal()
|
||||
playFromStart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlockScroll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
: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"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<button
|
||||
:aria-label="t('gallery.detail.close', locale)"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span
|
||||
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>
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="tutorial.videoSrc"
|
||||
:poster="tutorial.poster"
|
||||
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
</h2>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
120
apps/website/src/components/learning/TutorialsSection.vue
Normal file
120
apps/website/src/components/learning/TutorialsSection.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
getTutorialPosterSrc,
|
||||
learningTutorials
|
||||
} from '../../data/learningTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import MaskRevealButton from '../common/MaskRevealButton.vue'
|
||||
import TutorialDetailDialog from './TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeTutorialId = ref<string | null>(null)
|
||||
const activeTutorial = () =>
|
||||
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ t('learning.tutorials.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
|
||||
>
|
||||
<li
|
||||
v-for="tutorial in learningTutorials"
|
||||
:key="tutorial.id"
|
||||
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
|
||||
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
|
||||
@click="activeTutorialId = tutorial.id"
|
||||
>
|
||||
<video
|
||||
:src="getTutorialPosterSrc(tutorial)"
|
||||
:poster="tutorial.poster"
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
|
||||
>
|
||||
<svg
|
||||
class="ml-1 size-5 text-white lg:size-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<MaskRevealButton
|
||||
v-if="tutorial.href"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
<li v-for="tag in tutorial.tags" :key="tag">
|
||||
<Badge>{{ t(tag, locale) }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<TutorialDetailDialog
|
||||
v-if="activeTutorial()"
|
||||
:tutorial="activeTutorial()!"
|
||||
:locale="locale"
|
||||
@close="activeTutorialId = null"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
152
apps/website/src/components/models/ModelCreationsSection.vue
Normal file
152
apps/website/src/components/models/ModelCreationsSection.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryCard from '../gallery/GalleryCard.vue'
|
||||
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
|
||||
|
||||
const { 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)
|
||||
|
||||
function openDetail(index: number) {
|
||||
modalIndex.value = index
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
const title = t('models.list.creations.title', locale).replace(
|
||||
'{name}',
|
||||
modelName
|
||||
)
|
||||
const ctaLabel = t('models.list.creations.cta', locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-testid="model-creations"
|
||||
class="flex flex-col items-center px-4 py-16 lg:px-20 lg:pt-36"
|
||||
>
|
||||
<h2
|
||||
class="max-w-4xl text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-16 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ ctaLabel }}
|
||||
</BrandButton>
|
||||
|
||||
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(0, 2)"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(2, 5)"
|
||||
:key="i + 2"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
@click="openDetail(i + 2)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 max-lg:pb-6 lg:hidden"
|
||||
>
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
mobile
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GalleryDetailModal
|
||||
v-if="modalOpen"
|
||||
:items
|
||||
:initial-index="modalIndex"
|
||||
:locale
|
||||
@close="modalOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
56
apps/website/src/components/models/ModelsHeroSection.vue
Normal file
56
apps/website/src/components/models/ModelsHeroSection.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
modelName,
|
||||
ctaHref,
|
||||
videoSrc,
|
||||
videoAriaLabel
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
modelName: string
|
||||
ctaHref: string
|
||||
videoSrc: string
|
||||
videoAriaLabel?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col items-center px-6 pt-16 text-center lg:pt-36">
|
||||
<h1
|
||||
class="max-w-4xl text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ modelName }} in <span class="text-primary-comfy-yellow">ComfyUI</span>
|
||||
</h1>
|
||||
<p
|
||||
class="mt-6 max-w-2xl text-sm text-pretty text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-10 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
|
||||
</BrandButton>
|
||||
<div class="mt-16 w-full max-w-5xl">
|
||||
<video
|
||||
:src="videoSrc"
|
||||
:aria-label="videoAriaLabel || undefined"
|
||||
:aria-hidden="videoAriaLabel ? undefined : true"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="rounded-4.5xl size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
|
||||
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
|
||||
<p
|
||||
@@ -88,18 +88,18 @@ function getCardClass(layoutClass: string): string {
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
|
||||
class="text-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('cloud.aiModels.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
|
||||
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('cloud.aiModels.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-24 w-full">
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
@@ -180,14 +180,15 @@ function getCardClass(layoutClass: string): string {
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
class="mt-4 w-full max-w-md text-center lg:mt-8 lg:w-auto"
|
||||
size="lg"
|
||||
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
|
||||
>
|
||||
<span class="lg:hidden">{{
|
||||
t('cloud.aiModels.ctaMobile', locale)
|
||||
}}</span>
|
||||
<span class="hidden lg:inline">{{
|
||||
<!-- <span class="lg:hidden"> -->
|
||||
{{ t('cloud.aiModels.ctaMobile', locale) }}
|
||||
<!-- </span> -->
|
||||
<!-- <span class="hidden lg:inline">{{
|
||||
t('cloud.aiModels.ctaDesktop', locale)
|
||||
}}</span>
|
||||
}}</span> -->
|
||||
</BrandButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
|
||||
hubSlug: 'seedance',
|
||||
featured: true
|
||||
},
|
||||
'grok-image': {
|
||||
'grok-imagine': {
|
||||
hubSlug: 'grok',
|
||||
featured: false
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ const baseRoutes = {
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
affiliates: '/affiliates',
|
||||
|
||||
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
|
||||
@@ -7,6 +7,8 @@ export interface GalleryItem {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,6 +1,22 @@
|
||||
type Locale = 'en' | 'zh-CN'
|
||||
|
||||
const translations = {
|
||||
// Tags (global, reusable across sections)
|
||||
'tags.partnerNodes': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作伙伴节点'
|
||||
},
|
||||
'tags.imageToVideo': {
|
||||
en: 'Image To Video',
|
||||
'zh-CN': '图像生成视频'
|
||||
},
|
||||
|
||||
// CTAs (global, reusable across sections)
|
||||
'cta.tryWorkflow': {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
en: 'Professional Control\nof Visual AI',
|
||||
@@ -1435,6 +1451,62 @@ const translations = {
|
||||
'player.subtitlesOn': { en: 'Subtitles on', 'zh-CN': '开启字幕' },
|
||||
'player.subtitlesOff': { en: 'Subtitles off', 'zh-CN': '关闭字幕' },
|
||||
|
||||
// LearningHeroSection
|
||||
'learning.heroTitle.before': { en: 'Learn', 'zh-CN': '学习' },
|
||||
'learning.heroTitle.after': { en: '.', 'zh-CN': '。' },
|
||||
'learning.heroTitle.line2': {
|
||||
en: 'Build what doesn’t exist yet.',
|
||||
'zh-CN': '构建尚未存在之物。'
|
||||
},
|
||||
|
||||
// LearningFeaturedWorkflowSection
|
||||
'learning.featured.title': {
|
||||
en: 'Sky Replacement',
|
||||
'zh-CN': '天空替换'
|
||||
},
|
||||
'learning.featured.author': {
|
||||
en: 'by Doug Hogan',
|
||||
'zh-CN': '作者:Doug Hogan'
|
||||
},
|
||||
'learning.featured.description': {
|
||||
en: 'A sky replacement workflow built on Wan AI models. WanVideoSampler and WanVideoDecode synthesize new sky visuals into existing footage. CLIPVisionLoader and WanVideoClipVisionEncode ensure replacements feel native, not composited.',
|
||||
'zh-CN':
|
||||
'基于 Wan AI 模型构建的天空替换工作流。WanVideoSampler 与 WanVideoDecode 将全新的天空视觉合成到现有素材中。CLIPVisionLoader 与 WanVideoClipVisionEncode 确保替换效果自然融合,而非生硬叠加。'
|
||||
},
|
||||
'learning.featured.watchDemo': {
|
||||
en: 'Watch Demo',
|
||||
'zh-CN': '观看演示'
|
||||
},
|
||||
|
||||
// LearningTutorialsSection
|
||||
'learning.tutorials.heading': {
|
||||
en: 'Featured Demos',
|
||||
'zh-CN': '精选演示'
|
||||
},
|
||||
'learning.tutorials.titlePrefix': {
|
||||
en: 'Learn how to:',
|
||||
'zh-CN': '学习如何:'
|
||||
},
|
||||
|
||||
// LearningCallToActionSection
|
||||
'learning.cta.heading': {
|
||||
en: 'Schedule a demo and see how ComfyUI fits your team’s creative needs.',
|
||||
'zh-CN': '预约演示,了解 ComfyUI 如何契合你的团队创作需求。'
|
||||
},
|
||||
'learning.cta.contactSales': {
|
||||
en: 'Contact Sales',
|
||||
'zh-CN': '联系销售'
|
||||
},
|
||||
|
||||
// LearningEventsSection
|
||||
'learning.events.heading': { en: 'Events', 'zh-CN': '活动' },
|
||||
'learning.events.description': {
|
||||
en: 'Check out our upcoming live streams and community meetings. We’re always open to your questions, ideas, and conversations.',
|
||||
'zh-CN':
|
||||
'查看我们即将举办的直播和社区聚会。我们随时欢迎你的提问、想法和交流。'
|
||||
},
|
||||
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
|
||||
|
||||
// GalleryHeroSection
|
||||
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
|
||||
'gallery.heroTitle.before': {
|
||||
@@ -1471,9 +1543,13 @@ const translations = {
|
||||
},
|
||||
'about.hero.body': {
|
||||
en: 'The team behind Comfy is small, intense, and building what we intend to be our life\u2019s work.',
|
||||
'zh-CN': 'Comfy 背后的团队规模虽小,但充满热情,致力于打造我们毕生的事业。'
|
||||
'zh-CN':
|
||||
'Comfy \u80cc\u540e\u7684\u56e2\u961f\u89c4\u6a21\u867d\u5c0f\uff0c\u4f46\u5145\u6ee1\u70ed\u60c5\uff0c\u81f4\u529b\u4e8e\u6253\u9020\u6211\u4eec\u6bd5\u751f\u7684\u4e8b\u4e1a\u3002'
|
||||
},
|
||||
'about.hero.cta': {
|
||||
en: 'SEE OPEN ROLES',
|
||||
'zh-CN': '\u67e5\u770b\u5f00\u653e\u804c\u4f4d'
|
||||
},
|
||||
'about.hero.cta': { en: 'SEE OPEN ROLES', 'zh-CN': '查看开放职位' },
|
||||
|
||||
// AboutStorySection
|
||||
'about.story.label': { en: 'OUR STORY', 'zh-CN': '我们的故事' },
|
||||
@@ -1743,6 +1819,7 @@ const translations = {
|
||||
},
|
||||
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
|
||||
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
|
||||
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
|
||||
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
|
||||
@@ -4381,6 +4458,76 @@ const translations = {
|
||||
'zh-CN': '支持的模型'
|
||||
},
|
||||
|
||||
// Models list page (/models)
|
||||
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
|
||||
'models.list.heroCta': {
|
||||
en: 'Try {name} Now',
|
||||
'zh-CN': '立即试用 {name}'
|
||||
},
|
||||
'models.list.creations.title': {
|
||||
en: '{name} Image and Video Creations',
|
||||
'zh-CN': '{name} 图像与视频创作'
|
||||
},
|
||||
'models.list.creations.cta': {
|
||||
en: 'Explore Workflows',
|
||||
'zh-CN': '探索工作流'
|
||||
},
|
||||
'models.list.heroTitle.before': {
|
||||
en: 'Run the world’s leading AI models in',
|
||||
'zh-CN': '在以下平台运行世界领先的 AI 模型'
|
||||
},
|
||||
'models.list.heroSubtitle': {
|
||||
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
|
||||
'zh-CN':
|
||||
'从开源扩散模型到合作伙伴 API,涵盖每一个主流模型,并附带可直接运行的社区工作流模板。'
|
||||
},
|
||||
'models.list.card.workflows': {
|
||||
en: '{count} workflows',
|
||||
'zh-CN': '{count} 个工作流'
|
||||
},
|
||||
'models.list.contact.label': {
|
||||
en: 'COMFY HUB',
|
||||
'zh-CN': 'COMFY HUB'
|
||||
},
|
||||
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
|
||||
'models.showcase.heading': {
|
||||
en: 'Run the world’s\nleading AI models',
|
||||
'zh-CN': '运行全球领先的\nAI 模型'
|
||||
},
|
||||
'models.showcase.subtitle': {
|
||||
en: 'New models are added as they launch.',
|
||||
'zh-CN': '新模型发布后会第一时间上线。'
|
||||
},
|
||||
'models.showcase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
'zh-CN': '探索工作流'
|
||||
},
|
||||
'models.showcase.card.grokImagine': {
|
||||
en: 'Grok Imagine',
|
||||
'zh-CN': 'Grok Imagine'
|
||||
},
|
||||
'models.showcase.card.nanoBananaPro': {
|
||||
en: 'Nano Banana Pro',
|
||||
'zh-CN': 'Nano Banana Pro'
|
||||
},
|
||||
'models.showcase.card.ltx23': {
|
||||
en: 'LTX 2.3',
|
||||
'zh-CN': 'LTX 2.3'
|
||||
},
|
||||
'models.showcase.card.qwenAdvancedEdit': {
|
||||
en: 'Advanced image\nediting with Qwen',
|
||||
'zh-CN': '使用 Qwen 进行\n高级图像编辑'
|
||||
},
|
||||
'models.showcase.card.wan22TextToVideo': {
|
||||
en: 'Wan 2.2\ntext to video',
|
||||
'zh-CN': 'Wan 2.2\n文字转视频'
|
||||
},
|
||||
'models.list.contact.heading': {
|
||||
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
|
||||
'zh-CN':
|
||||
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
'payment.success.label': {
|
||||
en: 'PAYMENT',
|
||||
@@ -4736,6 +4883,8 @@ const translations = {
|
||||
|
||||
type TranslationKey = keyof typeof translations
|
||||
|
||||
type LocalizedText = Record<Locale, string>
|
||||
|
||||
export function t(key: TranslationKey, locale: Locale = 'en'): string {
|
||||
return translations[key][locale] ?? translations[key].en
|
||||
}
|
||||
@@ -4746,4 +4895,4 @@ export function hasKey(key: string): boolean {
|
||||
return key in translations
|
||||
}
|
||||
|
||||
export type { Locale, TranslationKey }
|
||||
export type { Locale, LocalizedText, TranslationKey }
|
||||
|
||||
27
apps/website/src/pages/learning.astro
Normal file
27
apps/website/src/pages/learning.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/learning/HeroSection.vue'
|
||||
import FeaturedWorkflowSection from '../components/learning/FeaturedWorkflowSection.vue'
|
||||
import TutorialsSection from '../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../components/common/CallToActionSection.vue'
|
||||
// import EventsSection from '../components/common/EventsSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
import { externalLinks } from '../config/routes'
|
||||
// import { learningEvents } from '../data/events'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout title="Learning — Comfy">
|
||||
<HeroSection client:load />
|
||||
<FeaturedWorkflowSection client:visible />
|
||||
<TutorialsSection client:visible />
|
||||
<CallToActionSection
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
22
apps/website/src/pages/models.astro
Normal file
22
apps/website/src/pages/models.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
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'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Models — Comfy"
|
||||
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
|
||||
>
|
||||
<ModelsHeroSection
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/p/supported-models/grok-imagine"
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="Grok Imagine output created with ComfyUI"
|
||||
/>
|
||||
<ModelCreationsSection client:load />
|
||||
<AIModelsSection client:load />
|
||||
<ProductShowcaseSection client:load />
|
||||
</BaseLayout>
|
||||
27
apps/website/src/pages/zh-CN/learning.astro
Normal file
27
apps/website/src/pages/zh-CN/learning.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/learning/HeroSection.vue'
|
||||
import FeaturedWorkflowSection from '../../components/learning/FeaturedWorkflowSection.vue'
|
||||
import TutorialsSection from '../../components/learning/TutorialsSection.vue'
|
||||
import CallToActionSection from '../../components/common/CallToActionSection.vue'
|
||||
import EventsSection from '../../components/common/EventsSection.vue'
|
||||
import { getRoutes, externalLinks } from '../../config/routes'
|
||||
import { learningEvents } from '../../data/events'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout title="学习 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<FeaturedWorkflowSection locale="zh-CN" client:visible />
|
||||
<TutorialsSection locale="zh-CN" client:visible />
|
||||
<CallToActionSection
|
||||
locale="zh-CN"
|
||||
headingKey="learning.cta.heading"
|
||||
primaryLabelKey="learning.cta.contactSales"
|
||||
primaryHref={routes.contact}
|
||||
secondaryLabelKey="cta.tryWorkflow"
|
||||
secondaryHref={externalLinks.workflows}
|
||||
client:visible
|
||||
/>
|
||||
</BaseLayout>
|
||||
23
apps/website/src/pages/zh-CN/models.astro
Normal file
23
apps/website/src/pages/zh-CN/models.astro
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
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'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="模型 — Comfy"
|
||||
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
|
||||
>
|
||||
<ModelsHeroSection
|
||||
locale="zh-CN"
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/zh-CN/p/supported-models/grok-imagine"
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
|
||||
/>
|
||||
<ModelCreationsSection client:load locale="zh-CN" />
|
||||
<AIModelsSection client:load locale="zh-CN" />
|
||||
<ProductShowcaseSection client:load locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
Reference in New Issue
Block a user