Compare commits
7 Commits
version-bu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2968422e6 | ||
|
|
5acd76cb6d | ||
|
|
bc885f383c | ||
|
|
fc4d44c3db | ||
|
|
7f25d28b71 | ||
|
|
67b884d0f7 | ||
|
|
05efee07ce |
142
.github/workflows/publish-desktop-bridge-types.yaml
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
name: Publish Desktop Bridge Types
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to publish (e.g., 0.1.2)'
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
description: 'npm dist-tag to use'
|
||||
required: true
|
||||
default: latest
|
||||
type: string
|
||||
ref:
|
||||
description: 'Git ref to checkout (commit SHA, tag, or branch)'
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish_desktop_bridge_types:
|
||||
name: Publish @comfyorg/comfyui-desktop-bridge-types
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
|
||||
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
|
||||
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Determine ref to checkout
|
||||
id: resolve_ref
|
||||
env:
|
||||
REF: ${{ inputs.ref }}
|
||||
DEFAULT_REF: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$REF" ]; then
|
||||
REF="$DEFAULT_REF"
|
||||
fi
|
||||
if ! git check-ref-format --allow-onelevel "$REF"; then
|
||||
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.resolve_ref.outputs.ref }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
env:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
||||
|
||||
- name: Verify package
|
||||
id: pkg
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
|
||||
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
|
||||
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
|
||||
if [ "$VERSION" != "$INPUT_VERSION" ]; then
|
||||
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if version already on npm
|
||||
id: check_npm
|
||||
env:
|
||||
NAME: ${{ steps.pkg.outputs.name }}
|
||||
VER: ${{ steps.pkg.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STATUS=0
|
||||
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
|
||||
else
|
||||
if echo "$OUTPUT" | grep -q "E404"; then
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error title=Registry lookup failed::$OUTPUT" >&2
|
||||
exit "$STATUS"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Publish package
|
||||
if: steps.check_npm.outputs.exists == 'false'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
DIST_TAG: ${{ inputs.dist_tag }}
|
||||
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
|
||||
working-directory: packages/comfyui-desktop-bridge-types
|
||||
24
apps/website/components.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"font": "inter",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"pointer": true,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const TOP_LEVEL_LABELS = [
|
||||
'Products',
|
||||
'Pricing',
|
||||
'Community',
|
||||
'Company'
|
||||
] as const
|
||||
|
||||
test.describe('Desktop navigation @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
@@ -17,14 +24,10 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
|
||||
for (const label of [
|
||||
'PRODUCTS',
|
||||
'PRICING',
|
||||
'COMMUNITY',
|
||||
'RESOURCES',
|
||||
'COMPANY'
|
||||
]) {
|
||||
await expect(desktopLinks.getByText(label).first()).toBeVisible()
|
||||
for (const label of TOP_LEVEL_LABELS) {
|
||||
await expect(
|
||||
desktopLinks.getByText(label, { exact: true }).first()
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,11 +52,11 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
const productsButton = desktopLinks.getByRole('button', {
|
||||
name: /PRODUCTS/i
|
||||
name: 'Products'
|
||||
})
|
||||
await productsButton.hover()
|
||||
|
||||
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
|
||||
const dropdown = nav.getByTestId('nav-dropdown')
|
||||
for (const item of [
|
||||
'Comfy Desktop',
|
||||
'Comfy Cloud',
|
||||
@@ -67,19 +70,20 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
test('moving mouse away closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.locator('main').hover()
|
||||
const viewport = page.viewportSize()
|
||||
await page.mouse.move(10, (viewport?.height ?? 800) - 10)
|
||||
await expect(comfyLocal).toBeHidden()
|
||||
})
|
||||
|
||||
test('Escape key closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
@@ -105,11 +109,11 @@ test.describe('Mobile menu @mobile', () => {
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
const menu = page.getByRole('dialog')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
|
||||
await expect(menu.getByText(label).first()).toBeVisible()
|
||||
for (const label of ['Products', 'Pricing', 'Community']) {
|
||||
await expect(menu.getByText(label, { exact: true }).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -118,24 +122,14 @@ test.describe('Mobile menu @mobile', () => {
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await menu.getByText('PRODUCTS').first().click()
|
||||
const menu = page.getByRole('dialog')
|
||||
await menu.getByRole('button', { name: 'Products' }).click()
|
||||
|
||||
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
|
||||
|
||||
await menu.getByRole('button', { name: /BACK/i }).click()
|
||||
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('CTA buttons visible in mobile menu', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
).toBeVisible()
|
||||
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
|
||||
await expect(menu.getByRole('button', { name: 'Products' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
@@ -25,12 +25,15 @@
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@lucide/vue": "catalog:",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"three": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
@@ -43,6 +46,7 @@
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<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>
|
||||
@@ -114,7 +114,7 @@ function scrollToSection(id: string) {
|
||||
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<!-- Desktop sticky nav -->
|
||||
<aside class="scrollbar-none hidden lg:block lg:w-48 lg:shrink-0">
|
||||
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
<CategoryNav
|
||||
:categories="categories"
|
||||
@@ -135,7 +135,7 @@ function scrollToSection(id: string) {
|
||||
>
|
||||
<h2
|
||||
v-if="section.hasTitle"
|
||||
class="text-primary-comfy-canvas mb-6 text-2xl font-light"
|
||||
class="mb-6 text-2xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(key(section.id, 'title'), locale) }}
|
||||
</h2>
|
||||
@@ -144,7 +144,7 @@ function scrollToSection(id: string) {
|
||||
<!-- Paragraph -->
|
||||
<p
|
||||
v-if="block.type === 'paragraph'"
|
||||
class="text-primary-comfy-canvas mt-4 text-sm/relaxed"
|
||||
class="mt-4 text-sm/relaxed text-primary-comfy-canvas"
|
||||
v-html="t(key(section.id, `block.${i}`), locale)"
|
||||
/>
|
||||
|
||||
@@ -167,7 +167,7 @@ function scrollToSection(id: string) {
|
||||
locale
|
||||
).split('\n')"
|
||||
:key="j"
|
||||
class="text-primary-comfy-canvas flex items-start gap-2"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow mt-1.5 size-1.5 shrink-0 rounded-full"
|
||||
@@ -187,7 +187,7 @@ function scrollToSection(id: string) {
|
||||
locale
|
||||
).split('\n')"
|
||||
:key="j"
|
||||
class="text-primary-comfy-canvas flex items-start gap-3"
|
||||
class="flex items-start gap-3 text-primary-comfy-canvas"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow shrink-0 font-semibold tabular-nums"
|
||||
@@ -205,7 +205,7 @@ function scrollToSection(id: string) {
|
||||
:alt="t(key(section.id, `block.${i}.alt`), locale)"
|
||||
class="w-full rounded-2xl object-cover"
|
||||
/>
|
||||
<figcaption class="text-primary-comfy-canvas mt-3 text-xs">
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.caption`), locale) }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -221,7 +221,7 @@ function scrollToSection(id: string) {
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-canvas text-lg/relaxed font-light italic"
|
||||
class="text-lg/relaxed font-light text-primary-comfy-canvas italic"
|
||||
>
|
||||
"{{ t(key(section.id, `block.${i}.text`), locale) }}"
|
||||
</p>
|
||||
@@ -238,17 +238,17 @@ function scrollToSection(id: string) {
|
||||
<SectionLabel>
|
||||
{{ t(key(section.id, `block.${i}.label`), locale) }}
|
||||
</SectionLabel>
|
||||
<p class="text-primary-comfy-canvas mt-2 text-sm font-semibold">
|
||||
<p class="mt-2 text-sm font-semibold text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.name`), locale) }}
|
||||
</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.role`), locale) }}
|
||||
</p>
|
||||
<template v-if="hasKey(key(section.id, `block.${i}.name2`))">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm font-semibold">
|
||||
<p class="mt-4 text-sm font-semibold text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.name2`), locale) }}
|
||||
</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
{{ t(key(section.id, `block.${i}.role2`), locale) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
85
apps/website/src/components/common/HeaderMain/HeaderMain.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import { externalLinks, getRoutes } from '../../../config/routes.ts'
|
||||
import GitHubStarBadge from '../GitHubStarBadge.vue'
|
||||
import HeaderMainDesktop from './HeaderMainDesktop.vue'
|
||||
import HeaderMainMobile from './HeaderMainMobile.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { locale = 'en', githubStars = '' } = defineProps<{
|
||||
locale?: Locale
|
||||
githubStars?: string
|
||||
}>()
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const ctaButtons = [
|
||||
{
|
||||
prefix: t('nav.ctaDesktopPrefix', locale),
|
||||
core: t('nav.ctaDesktopCore', locale),
|
||||
ariaLabel: t('nav.downloadLocal', locale),
|
||||
href: routes.download,
|
||||
primary: false
|
||||
},
|
||||
{
|
||||
prefix: t('nav.ctaCloudPrefix', locale),
|
||||
core: t('nav.ctaCloudCore', locale),
|
||||
ariaLabel: t('nav.launchCloud', locale),
|
||||
href: externalLinks.cloud,
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
:href="routes.home"
|
||||
class="inline-grid h-10 shrink-0 grid-cols-1 grid-rows-1 transition-[width]"
|
||||
aria-label="Comfy home"
|
||||
>
|
||||
<img
|
||||
src="/icons/logomark.svg"
|
||||
alt="Comfy"
|
||||
class="col-span-full row-span-full h-8"
|
||||
/>
|
||||
<div
|
||||
class="relative col-span-full row-span-full h-10 w-0 overflow-clip transition-[width] xl:w-36"
|
||||
>
|
||||
<img
|
||||
src="/icons/logo.svg"
|
||||
alt="Comfy"
|
||||
class="absolute top-0 left-0 h-10 w-36 max-w-none object-contain object-left"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<HeaderMainDesktop :locale class="hidden lg:block" />
|
||||
<HeaderMainMobile :locale class="lg:hidden" />
|
||||
|
||||
<!-- Desktop CTA buttons -->
|
||||
<div
|
||||
data-testid="desktop-nav-cta"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
>
|
||||
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
|
||||
<Button
|
||||
v-for="cta in ctaButtons"
|
||||
:key="cta.href"
|
||||
as="a"
|
||||
:href="cta.href"
|
||||
:variant="cta.primary ? 'default' : 'outline'"
|
||||
:aria-label="cta.ariaLabel"
|
||||
>
|
||||
<span
|
||||
><span class="hidden xl:inline-block">{{ cta.prefix }} </span
|
||||
>{{ cta.core }}</span
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import NavigationMenu from '@/components/ui/navigation-menu/NavigationMenu.vue'
|
||||
import NavigationMenuContent from '@/components/ui/navigation-menu/NavigationMenuContent.vue'
|
||||
import NavigationMenuItem from '@/components/ui/navigation-menu/NavigationMenuItem.vue'
|
||||
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
|
||||
import NavigationMenuList from '@/components/ui/navigation-menu/NavigationMenuList.vue'
|
||||
import NavigationMenuTrigger from '@/components/ui/navigation-menu/NavigationMenuTrigger.vue'
|
||||
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu/navigationMenuTriggerStyle'
|
||||
|
||||
import {
|
||||
isHrefActive,
|
||||
useCurrentPath
|
||||
} from '../../../composables/useCurrentPath'
|
||||
import { getMainNavigation } from '../../../data/mainNavigation'
|
||||
import type { NavItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavColumn from './NavColumn.vue'
|
||||
import NavFeaturedCard from './NavFeaturedCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const mainNavigation = getMainNavigation(locale)
|
||||
const currentPath = useCurrentPath()
|
||||
|
||||
function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
if (navItem.href) return isHrefActive(navItem.href, path)
|
||||
return (
|
||||
navItem.columns?.some((column) =>
|
||||
column.items.some((item) => isHrefActive(item.href, path))
|
||||
) ?? false
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenu data-testid="desktop-nav-links">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem
|
||||
v-for="navItem in mainNavigation"
|
||||
:key="navItem.label"
|
||||
>
|
||||
<template v-if="navItem.columns?.length">
|
||||
<NavigationMenuTrigger
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
>
|
||||
{{ navItem.label }}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
|
||||
<ul class="flex w-max gap-16">
|
||||
<NavFeaturedCard
|
||||
v-if="navItem.featured"
|
||||
:featured="navItem.featured"
|
||||
/>
|
||||
<NavColumn
|
||||
v-for="column in navItem.columns"
|
||||
:key="column.header"
|
||||
:column="column"
|
||||
:locale="locale"
|
||||
:current-path="currentPath"
|
||||
/>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</template>
|
||||
<NavigationMenuLink
|
||||
v-else
|
||||
as-child
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
:class="navigationMenuTriggerStyle()"
|
||||
>
|
||||
<a :href="navItem.href" class="ppformula-text-center">{{
|
||||
navItem.label
|
||||
}}</a>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import BreadthumbIcon from '@/components/icons/BreadthumbIcon.vue'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { getMainNavigation } from '../../../data/mainNavigation'
|
||||
import { getRoutes } from '../../../config/routes.ts'
|
||||
import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
import Sheet from '@/components/ui/sheet/Sheet.vue'
|
||||
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||
import SheetTrigger from '@/components/ui/sheet/SheetTrigger.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const routes = getRoutes(locale)
|
||||
const mainNavigation = getMainNavigation(locale)
|
||||
|
||||
const isOpen = ref(false)
|
||||
const activeSection = ref<string | null>(null)
|
||||
|
||||
const activeItem = computed(() =>
|
||||
mainNavigation.find(
|
||||
(item) => item.label === activeSection.value && item.columns
|
||||
)
|
||||
)
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
lockScroll()
|
||||
} else {
|
||||
unlockScroll()
|
||||
activeSection.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isOpen.value) unlockScroll({ skipRestore: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Sheet v-model:open="isOpen">
|
||||
<SheetTrigger
|
||||
:aria-label="t('nav.toggleMenu', locale)"
|
||||
class="bg-primary-comfy-yellow grid size-10 shrink-0 cursor-pointer place-items-center rounded-xl text-primary-comfy-ink hover:opacity-90"
|
||||
>
|
||||
<BreadthumbIcon class="h-3 w-5 text-primary-comfy-ink" />
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="right"
|
||||
class="flex size-full flex-col px-6 py-5 sm:max-w-none"
|
||||
:close-label="t('nav.close', locale)"
|
||||
>
|
||||
<SheetHeader class="sr-only">
|
||||
<SheetTitle>{{ t('nav.menu', locale) }}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{{ t('nav.mobileMenuDescription', locale) }}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div>
|
||||
<a
|
||||
:href="routes.home"
|
||||
class="focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex w-auto shrink-0 focus-visible:ring-3"
|
||||
>
|
||||
<img src="/icons/logomark.svg" alt="" class="h-11 w-auto" />
|
||||
<span class="sr-only">{{ t('nav.home', locale) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-4 flex-1 overflow-hidden">
|
||||
<!-- Top-level nav -->
|
||||
<nav
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 overflow-y-auto p-1',
|
||||
activeItem ? 'opacity-0' : ''
|
||||
)
|
||||
"
|
||||
:aria-label="t('nav.menu', locale)"
|
||||
:inert="activeItem ? true : undefined"
|
||||
>
|
||||
<ul class="flex flex-col gap-y-8">
|
||||
<li v-for="item in mainNavigation" :key="item.label">
|
||||
<Button
|
||||
:as="item.columns ? 'button' : 'a'"
|
||||
variant="navMuted"
|
||||
:type="item.columns ? 'button' : undefined"
|
||||
:href="item.columns ? undefined : item.href"
|
||||
@click="item.columns && (activeSection = item.label)"
|
||||
>
|
||||
{{ item.label }}
|
||||
<template #append>
|
||||
<ChevronRight class="size-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Drill-down sub-panel -->
|
||||
<div
|
||||
class="absolute inset-0 bg-primary-comfy-ink transition-transform duration-300 ease-out"
|
||||
:class="
|
||||
activeItem
|
||||
? 'translate-x-0'
|
||||
: 'pointer-events-none translate-x-full'
|
||||
"
|
||||
:inert="activeItem ? undefined : true"
|
||||
:aria-hidden="!activeItem"
|
||||
>
|
||||
<div class="size-full overflow-y-auto py-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
@click="activeSection = null"
|
||||
>
|
||||
<template #prepend>
|
||||
<ChevronLeft />
|
||||
</template>
|
||||
{{ t('nav.back', locale) }}
|
||||
</Button>
|
||||
|
||||
<div v-if="activeItem" class="mt-6 flex flex-col gap-y-12">
|
||||
<div
|
||||
v-for="column in activeItem.columns"
|
||||
:key="column.header"
|
||||
class="flex flex-col gap-y-3"
|
||||
>
|
||||
<p
|
||||
class="text-primary-warm-gray text-base font-bold tracking-wider uppercase"
|
||||
>
|
||||
{{ column.header }}
|
||||
</p>
|
||||
<Button
|
||||
v-for="link in column.items"
|
||||
:key="link.label"
|
||||
:href="link.href"
|
||||
variant="nav"
|
||||
as="a"
|
||||
:target="link.external ? '_blank' : undefined"
|
||||
:rel="link.external ? 'noopener noreferrer' : undefined"
|
||||
>
|
||||
<NavLinkContent :item="link" :locale="locale" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-8 bg-linear-to-b from-primary-comfy-ink to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t from-primary-comfy-ink to-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
36
apps/website/src/components/common/HeaderMain/NavColumn.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
|
||||
|
||||
import { isHrefActive } from '../../../composables/useCurrentPath'
|
||||
import type { NavColumn } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
|
||||
defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="flex flex-col space-y-4">
|
||||
<p class="font-formula text-primary-warm-gray pl-2 text-sm font-medium">
|
||||
{{ column.header }}
|
||||
</p>
|
||||
<ul class="flex flex-col">
|
||||
<li v-for="item in column.items" :key="item.label">
|
||||
<NavigationMenuLink
|
||||
as-child
|
||||
:active="isHrefActive(item.href, currentPath)"
|
||||
class="hover:bg-transparency-white-t4"
|
||||
>
|
||||
<a
|
||||
:href="item.href"
|
||||
:target="item.external ? '_blank' : undefined"
|
||||
:rel="item.external ? 'noopener noreferrer' : undefined"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
<NavLinkContent :item="item" :locale="locale" />
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import ButtonPill from '@/components/ui/button-pill/ButtonPill.vue'
|
||||
|
||||
import type { NavFeatured } from '../../../data/mainNavigation'
|
||||
|
||||
defineProps<{ featured: NavFeatured }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="shrink-0">
|
||||
<a
|
||||
:href="featured.cta.href"
|
||||
:aria-label="featured.cta.ariaLabel"
|
||||
class="group/pill-trigger relative block"
|
||||
>
|
||||
<img
|
||||
class="aspect-4/3 w-62 max-w-none rounded-xl"
|
||||
:src="featured.imageSrc"
|
||||
:alt="featured.imageAlt ?? ''"
|
||||
/>
|
||||
<p class="mt-4 font-extrabold uppercase">
|
||||
{{ featured.title }}
|
||||
</p>
|
||||
<div class="mt-1">
|
||||
<ButtonPill as="span" icon-position="left" variant="ghost">
|
||||
{{ featured.cta.label }}
|
||||
</ButtonPill>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
|
||||
import type { NavColumnItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<Badge v-if="item.badge" size="xs" variant="accent">
|
||||
{{ t('nav.badgeNew', locale) }}
|
||||
</Badge>
|
||||
<ArrowUpRight
|
||||
v-if="item.external"
|
||||
class="text-primary-comfy-yellow size-4"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,82 +0,0 @@
|
||||
<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>
|
||||
@@ -1,186 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
import type { NavLink } from './NavDesktopLink.vue'
|
||||
|
||||
interface CtaLink {
|
||||
label: string
|
||||
href: string
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
open = false,
|
||||
navigating = false,
|
||||
links = [],
|
||||
ctaLinks = [],
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
open?: boolean
|
||||
navigating?: boolean
|
||||
links?: NavLink[]
|
||||
ctaLinks?: CtaLink[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | undefined>()
|
||||
const activeSection = ref<string | null>(null)
|
||||
|
||||
const activeSectionItems = computed(
|
||||
() => links.find((l) => l.label === activeSection.value)?.items
|
||||
)
|
||||
|
||||
function onNavigate() {
|
||||
activeSection.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab') return
|
||||
const menu = menuRef.value
|
||||
if (!menu) return
|
||||
const focusable = [...menu.querySelectorAll<HTMLElement>(FOCUSABLE)]
|
||||
if (!focusable.length) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => open,
|
||||
async (isOpen) => {
|
||||
if (isOpen) {
|
||||
lockScroll()
|
||||
await nextTick()
|
||||
const menu = menuRef.value
|
||||
const firstFocusable = menu?.querySelector<HTMLElement>(FOCUSABLE)
|
||||
firstFocusable?.focus()
|
||||
menu?.addEventListener('keydown', trapFocus)
|
||||
} else {
|
||||
menuRef.value?.removeEventListener('keydown', trapFocus)
|
||||
unlockScroll({ skipRestore: navigating })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
menuRef.value?.removeEventListener('keydown', trapFocus)
|
||||
if (open) unlockScroll({ skipRestore: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="open"
|
||||
id="site-mobile-menu"
|
||||
ref="menuRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:inert="!open"
|
||||
:aria-label="t('nav.menu', locale)"
|
||||
class="bg-primary-comfy-ink fixed inset-0 z-40 flex flex-col px-6 pt-24 pb-8 lg:hidden"
|
||||
>
|
||||
<!-- Main list -->
|
||||
<template v-if="!activeSection">
|
||||
<div class="flex flex-1 flex-col gap-8">
|
||||
<template v-for="link in links" :key="link.label">
|
||||
<button
|
||||
v-if="link.items"
|
||||
class="text-primary-comfy-canvas text-left text-3xl font-medium"
|
||||
@click="activeSection = link.label"
|
||||
>
|
||||
{{ link.label }}
|
||||
</button>
|
||||
<a
|
||||
v-else
|
||||
:href="link.href"
|
||||
class="text-primary-comfy-canvas text-3xl font-medium"
|
||||
@click="onNavigate"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<BrandButton
|
||||
v-for="cta in ctaLinks"
|
||||
:key="cta.href"
|
||||
:href="cta.href"
|
||||
:variant="cta.primary ? 'solid' : 'outline'"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
>
|
||||
{{ cta.label }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Drill-down sub-menu -->
|
||||
<template v-else>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<button
|
||||
class="text-primary-comfy-yellow mb-6 flex items-center gap-2 text-sm font-bold tracking-wide uppercase"
|
||||
@click="activeSection = null"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="bg-primary-comfy-yellow size-3 -translate-y-px rotate-180"
|
||||
style="
|
||||
mask: url('/icons/arrow-right.svg') center / contain no-repeat;
|
||||
"
|
||||
/>
|
||||
{{ t('nav.back', locale) }}
|
||||
</button>
|
||||
|
||||
<p class="text-primary-warm-gray mb-8 text-sm font-bold uppercase">
|
||||
{{ activeSection }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-8 pl-2">
|
||||
<a
|
||||
v-for="item in activeSectionItems"
|
||||
:key="item.href"
|
||||
:href="item.href"
|
||||
class="text-primary-comfy-canvas flex items-center gap-3 text-3xl font-medium"
|
||||
@click="onNavigate"
|
||||
>
|
||||
{{ item.label }}
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-xs font-semibold"
|
||||
>
|
||||
<span class="ppformula-text-center inline-block skew-x-12">{{
|
||||
item.badge
|
||||
}}</span>
|
||||
</span>
|
||||
<img
|
||||
v-if="item.external"
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,129 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type NavDropdownItem = {
|
||||
label: string
|
||||
href: string
|
||||
badge?: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export type NavLink = {
|
||||
label: string
|
||||
href?: string
|
||||
items?: NavDropdownItem[]
|
||||
}
|
||||
|
||||
const {
|
||||
link,
|
||||
currentPath,
|
||||
isOpen = false
|
||||
} = defineProps<{
|
||||
link: NavLink
|
||||
currentPath: string
|
||||
isOpen?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open', label: string): void
|
||||
(e: 'close'): void
|
||||
(e: 'toggle', label: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="link.items?.length && emit('open', link.label)"
|
||||
@mouseleave="emit('close')"
|
||||
@focusin="link.items?.length && emit('open', link.label)"
|
||||
@focusout="emit('close')"
|
||||
>
|
||||
<button
|
||||
v-if="link.items?.length"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'group flex cursor-pointer items-center gap-1.5 py-3 text-sm font-bold tracking-wide uppercase transition-colors',
|
||||
link.items.some((item) => currentPath === item.href)
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas hover:text-primary-warm-gray'
|
||||
)
|
||||
"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="isOpen"
|
||||
@click="emit('toggle', link.label)"
|
||||
>
|
||||
{{ link.label }}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'text-base leading-none transition-colors',
|
||||
link.items.some((item) => currentPath === item.href)
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas group-hover:text-primary-warm-gray'
|
||||
)
|
||||
"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
v-else
|
||||
:href="link.href"
|
||||
:aria-current="currentPath === link.href ? 'page' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1.5 py-3 text-sm font-bold tracking-wide uppercase transition-colors',
|
||||
currentPath === link.href
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas hover:text-primary-warm-gray'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-if="link.items?.length"
|
||||
v-show="isOpen"
|
||||
data-testid="nav-dropdown"
|
||||
class="bg-transparency-ink-t80 absolute top-full left-0 w-max rounded-xl p-2 shadow-lg backdrop-blur-2xl backdrop-saturate-150"
|
||||
>
|
||||
<a
|
||||
v-for="item in link.items"
|
||||
:key="item.href"
|
||||
:href="item.href"
|
||||
:aria-current="currentPath === item.href ? 'page' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-sm p-2 text-xs font-medium tracking-wide transition-colors',
|
||||
currentPath === item.href
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas hover:bg-transparency-white-t4 hover:text-white'
|
||||
)
|
||||
"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ item.label }}
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-[9px]/3 leading-none font-bold"
|
||||
>
|
||||
<span class="ppformula-text-center inline-block skew-x-12">{{
|
||||
item.badge
|
||||
}}</span>
|
||||
</span>
|
||||
<img
|
||||
v-if="item.external"
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="ml-auto size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,84 +0,0 @@
|
||||
<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>
|
||||
@@ -1,262 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
useBreakpoints,
|
||||
useEventListener,
|
||||
whenever
|
||||
} from '@vueuse/core'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
import GitHubStarBadge from './GitHubStarBadge.vue'
|
||||
import MobileMenu from './MobileMenu.vue'
|
||||
import NavDesktopLink from './NavDesktopLink.vue'
|
||||
import type { NavLink } from './NavDesktopLink.vue'
|
||||
|
||||
const { locale = 'en', githubStars = '' } = defineProps<{
|
||||
locale?: Locale
|
||||
githubStars?: string
|
||||
}>()
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const navLinks: NavLink[] = [
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
items: [
|
||||
{ label: t('nav.comfyLocal', locale), href: routes.download },
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{
|
||||
label: t('nav.comfyApi', locale),
|
||||
href: routes.api,
|
||||
badge: t('nav.badgeNew', locale)
|
||||
},
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
|
||||
]
|
||||
},
|
||||
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
|
||||
{
|
||||
label: t('nav.community', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.comfyHub', locale),
|
||||
href: externalLinks.workflows,
|
||||
badge: t('nav.badgeNew', locale)
|
||||
},
|
||||
{ label: t('nav.gallery', locale), href: routes.gallery }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('nav.resources', locale),
|
||||
items: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.github', locale),
|
||||
href: externalLinks.github,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.discord', locale),
|
||||
href: externalLinks.discord,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.youtube', locale),
|
||||
href: externalLinks.youtube,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('nav.company', locale),
|
||||
items: [
|
||||
{ label: t('nav.aboutUs', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.customerStories', locale), href: routes.customers }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: t('nav.downloadLocal', locale),
|
||||
prefix: 'DOWNLOAD',
|
||||
core: 'DESKTOP',
|
||||
href: routes.download,
|
||||
primary: false
|
||||
},
|
||||
{
|
||||
label: t('nav.launchCloud', locale),
|
||||
prefix: 'LAUNCH',
|
||||
core: 'CLOUD',
|
||||
href: externalLinks.cloud,
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
|
||||
const currentPath = ref('')
|
||||
const openDesktopDropdown = ref<string | null>(null)
|
||||
const mobileMenuOpen = ref(false)
|
||||
const isNavigating = ref(false)
|
||||
const hamburgerRef = ref<HTMLButtonElement | undefined>()
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen.value = false
|
||||
hamburgerRef.value?.focus()
|
||||
}
|
||||
|
||||
function toggleDesktopDropdown(label: string) {
|
||||
openDesktopDropdown.value = openDesktopDropdown.value === label ? null : label
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
closeMobileMenu()
|
||||
openDesktopDropdown.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onNavigate() {
|
||||
isNavigating.value = true
|
||||
closeMobileMenu()
|
||||
openDesktopDropdown.value = null
|
||||
currentPath.value = window.location.pathname
|
||||
await nextTick()
|
||||
isNavigating.value = false
|
||||
}
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isDesktop = breakpoints.greaterOrEqual('lg')
|
||||
|
||||
whenever(isDesktop, () => {
|
||||
mobileMenuOpen.value = false
|
||||
// Don't focus hamburger when transitioning to desktop — it's hidden
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
currentPath.value = window.location.pathname
|
||||
useEventListener(document, 'keydown', onKeydown)
|
||||
useEventListener(document, 'astro:after-swap', onNavigate)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MobileMenu
|
||||
:open="mobileMenuOpen"
|
||||
:navigating="isNavigating"
|
||||
:links="navLinks"
|
||||
:cta-links="ctaButtons"
|
||||
:locale="locale"
|
||||
@close="closeMobileMenu"
|
||||
/>
|
||||
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
:href="routes.home"
|
||||
class="inline-grid h-10 shrink-0 grid-cols-1 grid-rows-1 transition-[width]"
|
||||
aria-label="Comfy home"
|
||||
>
|
||||
<img
|
||||
src="/icons/logomark.svg"
|
||||
alt="Comfy"
|
||||
class="col-span-full row-span-full h-8"
|
||||
/>
|
||||
<div
|
||||
class="relative col-span-full row-span-full h-10 w-0 overflow-clip transition-[width] xl:w-36"
|
||||
>
|
||||
<img
|
||||
src="/icons/logo.svg"
|
||||
alt="Comfy"
|
||||
class="absolute top-0 left-0 h-10 w-36 max-w-none object-contain object-left"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<div
|
||||
data-testid="desktop-nav-links"
|
||||
class="hidden items-center gap-[clamp(1rem,2.5vw,2.5rem)] lg:flex"
|
||||
>
|
||||
<NavDesktopLink
|
||||
v-for="link in navLinks"
|
||||
:key="link.label"
|
||||
:link="link"
|
||||
:current-path="currentPath"
|
||||
:is-open="openDesktopDropdown === link.label"
|
||||
@open="openDesktopDropdown = $event"
|
||||
@close="openDesktopDropdown = null"
|
||||
@toggle="toggleDesktopDropdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Desktop CTA buttons -->
|
||||
<div
|
||||
data-testid="desktop-nav-cta"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
>
|
||||
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
|
||||
<BrandButton
|
||||
v-for="cta in ctaButtons"
|
||||
:key="cta.href"
|
||||
:href="cta.href"
|
||||
:variant="cta.primary ? 'solid' : 'outline'"
|
||||
size="nav"
|
||||
:aria-label="cta.label"
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-0 overflow-hidden align-bottom transition-[max-width] duration-300 ease-in-out xl:max-w-28"
|
||||
aria-hidden="true"
|
||||
>{{ cta.prefix }} </span
|
||||
>{{ cta.core }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<!-- Mobile hamburger -->
|
||||
<button
|
||||
ref="hamburgerRef"
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-xl lg:hidden',
|
||||
mobileMenuOpen
|
||||
? 'border-primary-comfy-yellow border-2 bg-transparent'
|
||||
: 'bg-primary-comfy-yellow'
|
||||
)
|
||||
"
|
||||
:aria-label="t('nav.toggleMenu', locale)"
|
||||
aria-controls="site-mobile-menu"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
>
|
||||
<img
|
||||
v-if="!mobileMenuOpen"
|
||||
src="/icons/breadthumb.svg"
|
||||
alt=""
|
||||
class="h-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/icons/close.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
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>
|
||||
@@ -1,110 +0,0 @@
|
||||
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
|
||||
>
|
||||
@@ -1,116 +0,0 @@
|
||||
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>
|
||||
@@ -58,13 +58,13 @@ function handleLogoLoad() {
|
||||
</SectionLabel>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-4xl/tight font-light lg:text-6xl"
|
||||
class="mt-4 text-4xl/tight font-light text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t('customers.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
ref="bodyRef"
|
||||
class="text-primary-comfy-canvas mt-6 max-w-lg text-base"
|
||||
class="mt-6 max-w-lg text-base text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('customers.hero.body', locale) }}
|
||||
</p>
|
||||
@@ -72,7 +72,12 @@ function handleLogoLoad() {
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
|
||||
<div
|
||||
id="hero-video"
|
||||
ref="videoRef"
|
||||
class="max-w-9xl mx-auto scroll-mt-24 px-4 pb-20 lg:scroll-mt-36 lg:px-20 lg:pb-40"
|
||||
>
|
||||
<VideoPlayer
|
||||
src="https://media.comfy.org/website/customers/blackmath/video.webm"
|
||||
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"
|
||||
|
||||
@@ -35,7 +35,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
{{ t(story.category, locale) }}
|
||||
</span>
|
||||
<h3
|
||||
class="text-primary-comfy-canvas mt-2 text-lg/snug font-light lg:text-xl/snug"
|
||||
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
|
||||
>
|
||||
{{ t(story.title, locale) }}
|
||||
</h3>
|
||||
|
||||
@@ -19,7 +19,7 @@ const {
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
|
||||
<h2 class="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
|
||||
{{ t('customers.story.whatsNext' as TranslationKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -35,18 +35,18 @@ const {
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
|
||||
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<a :href="href" class="flex items-center gap-3">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
|
||||
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
|
||||
>
|
||||
<span class="text-lg font-bold">›</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
|
||||
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ t('customers.story.viewArticle' as TranslationKey, locale) }}
|
||||
</span>
|
||||
|
||||
@@ -21,7 +21,7 @@ const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
|
||||
<h2 class="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
|
||||
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -37,18 +37,18 @@ const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
|
||||
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
|
||||
{{ nextTitle }}
|
||||
</h3>
|
||||
|
||||
<a :href="nextHref" class="flex items-center gap-3">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
|
||||
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
|
||||
>
|
||||
<span class="text-lg font-bold">›</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
|
||||
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
|
||||
</span>
|
||||
|
||||
20
apps/website/src/components/icons/BreadthumbIcon.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 12"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
:class="$props.class"
|
||||
>
|
||||
<path
|
||||
d="M20 1C20 1.55228 19.5523 2 19 2H17.5C16.6716 2 16 2.67157 16 3.5C16 4.32843 16.6716 5 17.5 5H19C19.5523 5 20 5.44772 20 6C20 6.55228 19.5523 7 19 7H7.5C6.67157 7 6 7.67157 6 8.5C6 9.32843 6.67157 10 7.5 10H19C19.5523 10 20 10.4477 20 11C20 11.5523 19.5523 12 19 12H1C0.447715 12 0 11.5523 0 11C0 10.4477 0.447715 10 1 10H2.5C3.32843 10 4 9.32843 4 8.5C4 7.67157 3.32843 7 2.5 7H1C0.447715 7 0 6.55228 0 6C0 5.44772 0.447715 5 1 5H12.5C13.3284 5 14 4.32843 14 3.5C14 2.67157 13.3284 2 12.5 2H1C0.447716 2 0 1.55228 0 1C0 0.447715 0.447715 0 1 0H19C19.5523 0 20 0.447715 20 1Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
learningTutorials
|
||||
} from '../../data/learningTutorials'
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../common/Badge.vue'
|
||||
import MaskRevealButton from '../common/MaskRevealButton.vue'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
import { ButtonMask } from '../ui/button-mask'
|
||||
import TutorialDetailDialog from './TutorialDetailDialog.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -76,13 +76,14 @@ const activeTutorial = () =>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<MaskRevealButton
|
||||
<ButtonMask
|
||||
v-if="tutorial.href"
|
||||
as="a"
|
||||
:href="tutorial.href"
|
||||
icon-position="right"
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="default"
|
||||
>
|
||||
{{ t('cta.tryWorkflow', locale) }}
|
||||
<template #icon>
|
||||
@@ -98,7 +99,7 @@ const activeTutorial = () =>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
</ButtonMask>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
|
||||
40
apps/website/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { BadgeVariants } from '.'
|
||||
import { badgeVariants } from '.'
|
||||
|
||||
const {
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
prependIcon,
|
||||
appendIcon
|
||||
} = defineProps<{
|
||||
variant?: BadgeVariants['variant']
|
||||
size?: BadgeVariants['size']
|
||||
class?: string
|
||||
prependIcon?: Component
|
||||
appendIcon?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:class="cn(badgeVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
<component :is="prependIcon" v-if="prependIcon" />
|
||||
</slot>
|
||||
<span class="ppformula-text-center">
|
||||
<slot />
|
||||
</span>
|
||||
<slot name="append">
|
||||
<component :is="appendIcon" v-if="appendIcon" />
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
24
apps/website/src/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const badgeVariants = cva({
|
||||
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent 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',
|
||||
accent:
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
},
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
xs: 'px-2 py-0.5 text-[9px]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
variant: 'default'
|
||||
}
|
||||
})
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import MaskRevealButton from './MaskRevealButton.vue'
|
||||
import ButtonMask from './ButtonMask.vue'
|
||||
|
||||
const meta: Meta<typeof MaskRevealButton> = {
|
||||
title: 'Website/Common/MaskRevealButton',
|
||||
component: MaskRevealButton,
|
||||
const meta: Meta<typeof ButtonMask> = {
|
||||
title: 'Website/UI/ButtonMask',
|
||||
component: ButtonMask,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
@@ -12,22 +12,19 @@ const meta: Meta<typeof MaskRevealButton> = {
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
as: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
options: ['button', 'a']
|
||||
},
|
||||
asChild: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
options: ['default', 'lg', 'icon']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
@@ -41,57 +38,57 @@ export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { href: '#' },
|
||||
args: { as: 'a', href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>`
|
||||
template: `<ButtonMask v-bind="args">Try Workflow</ButtonMask>`
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
args: { as: 'a', href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Read More</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Read More</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
args: { as: 'a', href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Go Back</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Go Back</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
export const DefaultSolid: Story = {
|
||||
args: { as: 'a', href: '#', size: 'default' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Try Workflow</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
args: { as: 'a', href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: `<MaskRevealButton v-bind="args">Let's Collaborate</MaskRevealButton>`
|
||||
template: `<ButtonMask v-bind="args">Let's Collaborate</ButtonMask>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
args: { as: 'a', href: '#' },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<MaskRevealButton v-bind="args">
|
||||
<ButtonMask v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
@@ -106,57 +103,53 @@ export const WithCustomIcon: Story = {
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</MaskRevealButton>
|
||||
</ButtonMask>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LabelVisible: Story = {
|
||||
args: { href: '#', hideLabel: false },
|
||||
args: { as: 'a', href: '#', hideLabel: false },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template:
|
||||
'<MaskRevealButton v-bind="args">Always Visible</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Always Visible</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
setup: () => ({ args }),
|
||||
template: '<MaskRevealButton v-bind="args">Unavailable</MaskRevealButton>'
|
||||
template: '<ButtonMask v-bind="args">Unavailable</ButtonMask>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { MaskRevealButton },
|
||||
components: { ButtonMask },
|
||||
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>
|
||||
<ButtonMask as="a" href="#" variant="solid" size="default">Default</ButtonMask>
|
||||
<ButtonMask as="a" href="#" variant="solid" size="lg">Large</ButtonMask>
|
||||
</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>
|
||||
<ButtonMask as="a" href="#" variant="ghost" size="default">Default</ButtonMask>
|
||||
<ButtonMask as="a" href="#" variant="ghost" size="lg">Large</ButtonMask>
|
||||
</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>
|
||||
<ButtonMask as="a" href="#" iconPosition="left" size="default">Default</ButtonMask>
|
||||
<ButtonMask as="a" href="#" iconPosition="left" size="lg">Large</ButtonMask>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
apps/website/src/components/ui/button-mask/ButtonMask.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { ButtonMaskVariants } from '.'
|
||||
import {
|
||||
BUTTON_MASK_LABEL_CLASS,
|
||||
buttonMaskBadgeVariants,
|
||||
buttonMaskVariants
|
||||
} from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonMaskVariants['variant']
|
||||
size?: ButtonMaskVariants['size']
|
||||
iconPosition?: ButtonMaskVariants['iconPosition']
|
||||
hideLabel?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
hideLabel = true,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button-mask"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(buttonMaskVariants({ variant, size, iconPosition }), className)"
|
||||
>
|
||||
<span
|
||||
:data-icon-position="iconPosition ?? 'right'"
|
||||
:data-hidden="hideLabel ? 'true' : 'false'"
|
||||
:class="BUTTON_MASK_LABEL_CLASS"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="buttonMaskBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<ChevronRight class="size-4" :stroke-width="2" />
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</Primitive>
|
||||
</template>
|
||||
94
apps/website/src/components/ui/button-mask/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export { default as ButtonMask } from './ButtonMask.vue'
|
||||
|
||||
export const buttonMaskVariants = cva({
|
||||
base: 'group/button-mask relative inline-flex w-fit uppercase cursor-pointer items-center overflow-hidden rounded-2xl p-1 text-sm font-bold tracking-wider 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: {
|
||||
default: 'h-10 px-6 py-2.5 has-[>svg]:px-3',
|
||||
lg: 'h-14 px-8 py-4 has-[>svg]:px-5'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{ size: 'default', iconPosition: 'right', class: 'ps-12 pe-4' },
|
||||
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
|
||||
{ size: 'default', iconPosition: 'left', class: 'ps-4 pe-12' },
|
||||
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'default',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const buttonMaskBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-xl transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'text-primary-comfy-yellow bg-primary-comfy-ink',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
default: 'size-8',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover/button-mask:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class: 'right-1 group-hover/button-mask:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover/button-mask:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class: 'left-1 group-hover/button-mask:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'default',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const BUTTON_MASK_LABEL_CLASS = [
|
||||
'ppformula-text-center 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/button-mask:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover/button-mask:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
|
||||
'group-focus-visible/button-mask:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible/button-mask:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
|
||||
].join(' ')
|
||||
|
||||
export type ButtonMaskVariants = VariantProps<typeof buttonMaskVariants>
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import PillButton from './PillButton.vue'
|
||||
import ButtonPill from './ButtonPill.vue'
|
||||
|
||||
const meta: Meta<typeof PillButton> = {
|
||||
title: 'Website/Common/PillButton',
|
||||
component: PillButton,
|
||||
const meta: Meta<typeof ButtonPill> = {
|
||||
title: 'Website/UI/ButtonPill',
|
||||
component: ButtonPill,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
@@ -12,22 +12,19 @@ const meta: Meta<typeof PillButton> = {
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
href: { control: 'text' },
|
||||
target: { control: 'text' },
|
||||
rel: { control: 'text' },
|
||||
type: {
|
||||
as: {
|
||||
control: { type: 'select' },
|
||||
options: ['button', 'submit', 'reset']
|
||||
options: ['button', 'a']
|
||||
},
|
||||
asChild: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
ariaLabel: { control: 'text' },
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'ghost']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg']
|
||||
options: ['default', 'lg', 'icon']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
@@ -41,57 +38,57 @@ export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AsAnchor: Story = {
|
||||
args: { href: '#' },
|
||||
args: { as: 'a', href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
template: `<ButtonPill v-bind="args">Let's Collaborate</ButtonPill>`
|
||||
})
|
||||
}
|
||||
|
||||
export const AsButton: Story = {
|
||||
args: { type: 'button' },
|
||||
args: { as: 'button', type: 'button' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Submit</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Submit</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { href: '#', variant: 'ghost' },
|
||||
args: { as: 'a', href: '#', variant: 'ghost' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Read More</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Read More</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const SmallSolid: Story = {
|
||||
args: { href: '#', size: 'sm' },
|
||||
export const DefaultSolid: Story = {
|
||||
args: { as: 'a', href: '#', size: 'default' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Try Workflow</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: { href: '#', size: 'lg' },
|
||||
args: { as: 'a', href: '#', size: 'lg' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
|
||||
template: `<ButtonPill v-bind="args">Let's Collaborate</ButtonPill>`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: { href: '#' },
|
||||
args: { as: 'a', href: '#' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<PillButton v-bind="args">
|
||||
<ButtonPill v-bind="args">
|
||||
Next Step
|
||||
<template #icon>
|
||||
<svg
|
||||
@@ -106,57 +103,55 @@ export const WithCustomIcon: Story = {
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</template>
|
||||
</PillButton>
|
||||
</ButtonPill>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const IconLeft: Story = {
|
||||
args: { href: '#', iconPosition: 'left' },
|
||||
args: { as: 'a', href: '#', iconPosition: 'left' },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Go Back</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Go Back</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const RevealLabelOnHover: Story = {
|
||||
args: { href: '#', hideLabel: true },
|
||||
args: { as: 'a', href: '#', hideLabel: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Try Workflow</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
setup: () => ({ args }),
|
||||
template: '<PillButton v-bind="args">Unavailable</PillButton>'
|
||||
template: '<ButtonPill v-bind="args">Unavailable</ButtonPill>'
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { PillButton },
|
||||
components: { ButtonPill },
|
||||
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>
|
||||
<ButtonPill as="a" href="#" variant="solid" size="default">Default</ButtonPill>
|
||||
<ButtonPill as="a" href="#" variant="solid" size="lg">Large</ButtonPill>
|
||||
</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>
|
||||
<ButtonPill as="a" href="#" variant="ghost" size="default">Default</ButtonPill>
|
||||
<ButtonPill as="a" href="#" variant="ghost" size="lg">Large</ButtonPill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
61
apps/website/src/components/ui/button-pill/ButtonPill.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { ButtonPillVariants } from '.'
|
||||
import { buttonPillBadgeVariants, buttonPillVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonPillVariants['variant']
|
||||
size?: ButtonPillVariants['size']
|
||||
iconPosition?: ButtonPillVariants['iconPosition']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
iconPosition,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button-pill"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(buttonPillVariants({ variant, size, iconPosition }), className)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'ppformula-text-center relative leading-none transition-all duration-500'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
:class="buttonPillBadgeVariants({ variant, size, iconPosition })"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex transition-transform duration-500">
|
||||
<slot name="icon">
|
||||
<ChevronRight class="size-4" :stroke-width="2" />
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
||||
</Primitive>
|
||||
</template>
|
||||
102
apps/website/src/components/ui/button-pill/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonPillVariants = cva({
|
||||
base: 'group/button-pill isolate relative inline-flex w-fit uppercase cursor-pointer items-center overflow-hidden rounded-2xl p-1 text-sm font-bold tracking-wider 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: {
|
||||
default: 'h-10 px-6 py-2.5 has-[>svg]:px-3',
|
||||
lg: 'h-14 px-8 py-4 has-[>svg]:px-5'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'right',
|
||||
class:
|
||||
'ps-6 pe-14 group-hover/pill-trigger:ps-14 group-hover/pill-trigger:pe-6 hover:ps-14 hover:pe-6'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class:
|
||||
'ps-8 pe-16 group-hover/pill-trigger:ps-16 group-hover/pill-trigger:pe-8 hover:ps-16 hover:pe-8'
|
||||
},
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'left',
|
||||
class:
|
||||
'ps-14 pe-6 group-hover/pill-trigger:ps-6 group-hover/pill-trigger:pe-14 hover:ps-6 hover:pe-14'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class:
|
||||
'ps-16 pe-8 group-hover/pill-trigger:ps-8 group-hover/pill-trigger:pe-16 hover:ps-8 hover:pe-16'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'default',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export const buttonPillBadgeVariants = cva({
|
||||
base: 'absolute z-10 flex items-center justify-center rounded-xl transition-all duration-500',
|
||||
variants: {
|
||||
variant: {
|
||||
solid: 'text-primary-comfy-yellow bg-primary-comfy-ink',
|
||||
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
default: 'size-8',
|
||||
lg: 'size-12'
|
||||
},
|
||||
iconPosition: {
|
||||
right: '',
|
||||
left: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'right',
|
||||
class:
|
||||
'right-1 group-hover/button-pill:right-[calc(100%-36px)] group-hover/pill-trigger:right-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'right',
|
||||
class:
|
||||
'right-1 group-hover/button-pill:right-[calc(100%-52px)] group-hover/pill-trigger:right-[calc(100%-52px)]'
|
||||
},
|
||||
{
|
||||
size: 'default',
|
||||
iconPosition: 'left',
|
||||
class:
|
||||
'left-1 group-hover/button-pill:left-[calc(100%-36px)] group-hover/pill-trigger:left-[calc(100%-36px)]'
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
iconPosition: 'left',
|
||||
class:
|
||||
'left-1 group-hover/button-pill:left-[calc(100%-52px)] group-hover/pill-trigger:left-[calc(100%-52px)]'
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
size: 'default',
|
||||
iconPosition: 'right'
|
||||
}
|
||||
})
|
||||
|
||||
export type ButtonPillVariants = VariantProps<typeof buttonPillVariants>
|
||||
50
apps/website/src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { Component, HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { buttonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
prependIcon?: Component
|
||||
appendIcon?: Component
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled,
|
||||
prependIcon,
|
||||
appendIcon
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(buttonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
<component :is="prependIcon" v-if="prependIcon" />
|
||||
</slot>
|
||||
<span class="ppformula-text-center">
|
||||
<slot />
|
||||
</span>
|
||||
<slot name="append">
|
||||
<component :is="appendIcon" v-if="appendIcon" />
|
||||
</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
31
apps/website/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
[
|
||||
"focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 aria-invalid:bg-destructive aria-invalid:hover:bg-destructive/90 inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-2xl text-sm font-bold tracking-wider whitespace-nowrap transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-10 px-6 py-2.5',
|
||||
lg: 'h-14 px-8 py-4 text-base'
|
||||
},
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary-comfy-yellow hover:bg-primary-comfy-yellow/90 text-primary-comfy-ink uppercase',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
|
||||
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
|
||||
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
|
||||
navMuted:
|
||||
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuRootEmits, NavigationMenuRootProps } from 'reka-ui'
|
||||
import { NavigationMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import NavigationMenuViewport from './NavigationMenuViewport.vue'
|
||||
|
||||
const {
|
||||
viewport = true,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
NavigationMenuRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
viewport?: boolean
|
||||
}
|
||||
>()
|
||||
const emits = defineEmits<NavigationMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(
|
||||
computed(() => ({ ...restProps })),
|
||||
emits
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="navigation-menu"
|
||||
:data-viewport="viewport"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
<NavigationMenuViewport v-if="viewport" />
|
||||
</NavigationMenuRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
NavigationMenuContentEmits,
|
||||
NavigationMenuContentProps
|
||||
} from 'reka-ui'
|
||||
import { NavigationMenuContent, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuContentProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
const emits = defineEmits<NavigationMenuContentEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(
|
||||
computed(() => ({ ...restProps })),
|
||||
emits
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuContent
|
||||
data-slot="navigation-menu-content"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'top-0 left-0 w-full px-8 py-6 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out md:absolute md:w-auto',
|
||||
'group-data-[viewport=false]/navigation-menu:bg-primary-comfy-ink-light group-data-[viewport=false]/navigation-menu:border-primary-comfy-ink-light group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-3xl group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:text-primary-comfy-canvas group-data-[viewport=false]/navigation-menu:shadow-sm group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</NavigationMenuContent>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuItemProps } from 'reka-ui'
|
||||
import { NavigationMenuItem } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuItemProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuItem
|
||||
data-slot="navigation-menu-item"
|
||||
v-bind="restProps"
|
||||
:class="cn('relative', className)"
|
||||
>
|
||||
<slot />
|
||||
</NavigationMenuItem>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuLinkEmits, NavigationMenuLinkProps } from 'reka-ui'
|
||||
import { NavigationMenuLink, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuLinkProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
const emits = defineEmits<NavigationMenuLinkEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(
|
||||
computed(() => ({ ...restProps })),
|
||||
emits
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuLink
|
||||
data-slot="navigation-menu-link"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'data-active:text-primary-comfy-yellow focus:bg-transparency-white-t4 ring-primary-comfy-yellow outline-primary-comfy-yellow flex flex-col gap-1 rounded-xl p-2 text-sm transition-[color,box-shadow] hover:text-white focus:text-white focus-visible:ring-4 focus-visible:outline-1 data-active:bg-transparent data-active:hover:bg-transparent [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</NavigationMenuLink>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuListProps } from 'reka-ui'
|
||||
import { NavigationMenuList, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuListProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuList
|
||||
data-slot="navigation-menu-list"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'group flex flex-1 list-none items-center justify-center gap-1',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</NavigationMenuList>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import type { NavigationMenuTriggerProps } from 'reka-ui'
|
||||
import { NavigationMenuTrigger, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { navigationMenuTriggerStyle } from './navigationMenuTriggerStyle'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
active,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
NavigationMenuTriggerProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
active?: boolean
|
||||
}
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenuTrigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:data-active="active ? '' : undefined"
|
||||
:class="cn(navigationMenuTriggerStyle(), 'group', className)"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
<slot />
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="relative ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuTrigger>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuViewportProps } from 'reka-ui'
|
||||
import { NavigationMenuViewport, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
NavigationMenuViewportProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute top-full left-0 isolate z-50 flex justify-center">
|
||||
<NavigationMenuViewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'origin-top-center bg-primary-comfy-ink-light border-primary-comfy-ink-light relative left-(--reka-navigation-menu-viewport-left) mt-1.5 h-(--reka-navigation-menu-viewport-height) w-full overflow-hidden rounded-3xl border text-primary-comfy-canvas shadow-sm data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-90 md:w-(--reka-navigation-menu-viewport-width)',
|
||||
className
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const navigationMenuTriggerStyle = cva([
|
||||
'group font-formula inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-2xl px-4 py-3 text-sm font-extrabold tracking-wider text-primary-comfy-canvas uppercase transition-[color,box-shadow] outline-none',
|
||||
'hover:text-primary-warm-gray',
|
||||
'data-[state=open]:hover:text-primary-comfy-yellow data-[state=open]:text-primary-comfy-yellow data-[state=open]:focus:text-primary-comfy-yellow',
|
||||
'data-active:text-primary-comfy-yellow data-active:hover:text-primary-comfy-yellow',
|
||||
'focus:bg-accent focus-visible:ring-primary-comfy-yellow focus:text-accent-foreground focus-visible:ring-3 focus-visible:outline-1',
|
||||
'disabled:pointer-events-none disabled:opacity-50'
|
||||
])
|
||||
15
apps/website/src/components/ui/sheet/Sheet.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-slot="slotProps" data-slot="sheet" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
12
apps/website/src/components/ui/sheet/SheetClose.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose data-slot="sheet-close" v-bind="props">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
62
apps/website/src/components/ui/sheet/SheetContent.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { X } from '@lucide/vue'
|
||||
import { DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import SheetClose from './SheetClose.vue'
|
||||
import SheetOverlay from './SheetOverlay.vue'
|
||||
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes['class']
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
closeLabel: string
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
side = 'right',
|
||||
closeLabel,
|
||||
class: classProp,
|
||||
...delegatedProps
|
||||
} = defineProps<SheetContentProps>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:class="
|
||||
cn(
|
||||
'fixed z-50 flex flex-col gap-4 bg-primary-comfy-ink transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'inset-x-0 top-0 h-auto data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
side === 'bottom' &&
|
||||
'inset-x-0 bottom-0 h-auto data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
classProp
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<SheetClose
|
||||
class="focus:ring-primary-comfy-yellow/50 text-primary-comfy-yellow border-primary-comfy-yellow absolute top-4 right-4 rounded-xl border p-2 ring-offset-primary-comfy-ink transition-opacity focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-6" />
|
||||
<span class="sr-only">{{ closeLabel }}</span>
|
||||
</SheetClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
apps/website/src/components/ui/sheet/SheetDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
DialogDescriptionProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="sheet-description"
|
||||
:class="cn('text-primary-warm-gray text-sm', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
15
apps/website/src/components/ui/sheet/SheetHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
28
apps/website/src/components/ui/sheet/SheetOverlay.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
DialogOverlayProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="sheet-overlay"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 fixed inset-0 z-50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
23
apps/website/src/components/ui/sheet/SheetTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
DialogTitleProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="sheet-title"
|
||||
:class="cn('text-primary-warm-white font-semibold', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
12
apps/website/src/components/ui/sheet/SheetTrigger.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger data-slot="sheet-trigger" v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
32
apps/website/src/composables/useCurrentPath.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
export function useCurrentPath() {
|
||||
const currentPath = ref('')
|
||||
|
||||
function update() {
|
||||
currentPath.value = window.location.pathname
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
update()
|
||||
document.addEventListener('astro:page-load', update)
|
||||
window.addEventListener('popstate', update)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('astro:page-load', update)
|
||||
window.removeEventListener('popstate', update)
|
||||
})
|
||||
|
||||
return currentPath
|
||||
}
|
||||
|
||||
export function isHrefActive(href: string, currentPath: string): boolean {
|
||||
if (!href || !currentPath || href.startsWith('http')) return false
|
||||
const path = href.split('#')[0].split('?')[0]
|
||||
if (!path) return false
|
||||
function norm(s: string) {
|
||||
return s.length > 1 ? s.replace(/\/$/, '') : s
|
||||
}
|
||||
return norm(path) === norm(currentPath)
|
||||
}
|
||||
@@ -62,9 +62,12 @@ export const externalLinks = {
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
} as const
|
||||
|
||||
193
apps/website/src/data/mainNavigation.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
export type NavColumnItem = {
|
||||
label: string
|
||||
href: string
|
||||
badge?: 'new'
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export type NavColumn = {
|
||||
header: string
|
||||
items: NavColumnItem[]
|
||||
}
|
||||
|
||||
export type NavFeatured = {
|
||||
imageSrc: string
|
||||
imageAlt?: string
|
||||
title: string
|
||||
cta: {
|
||||
label: string
|
||||
ariaLabel?: string
|
||||
href: string
|
||||
}
|
||||
}
|
||||
|
||||
export type NavItem =
|
||||
| {
|
||||
label: string
|
||||
columns: NavColumn[]
|
||||
featured?: NavFeatured
|
||||
href?: never
|
||||
}
|
||||
| { label: string; href: string; columns?: never; featured?: never }
|
||||
|
||||
export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
const routes = getRoutes(locale)
|
||||
return [
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('nav.products', locale),
|
||||
items: [
|
||||
{ label: t('nav.comfyLocal', locale), href: routes.download },
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{
|
||||
label: t('nav.comfyApi', locale),
|
||||
href: routes.api,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.comfyEnterprise', locale),
|
||||
href: routes.cloudEnterprise
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colFeatures', locale),
|
||||
items: [
|
||||
// TODO: no page yet — re-enable when landing pages ship
|
||||
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
|
||||
// { label: t('nav.appMode', locale), href: '#' },
|
||||
// { label: t('nav.agentSkills', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
|
||||
{
|
||||
label: t('nav.community', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
|
||||
imageAlt: t('nav.featuredCommunityAlt', locale),
|
||||
title: t('nav.featuredCommunityTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.watchDemo', locale),
|
||||
ariaLabel: t('nav.featuredCommunityCtaAria', locale),
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('nav.colPrograms', locale),
|
||||
items: [
|
||||
{ label: t('nav.comfyHub', locale), href: externalLinks.workflows },
|
||||
{ label: t('nav.gallery', locale), href: routes.gallery },
|
||||
{
|
||||
label: t('nav.affiliates', locale),
|
||||
href: routes.affiliates,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.learning', locale),
|
||||
href: routes.learning,
|
||||
badge: 'new'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colConnect', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.discord', locale),
|
||||
href: externalLinks.discord,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.github', locale),
|
||||
href: externalLinks.github,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.youtube', locale),
|
||||
href: externalLinks.youtube,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.reddit', locale),
|
||||
href: externalLinks.reddit,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.x', locale),
|
||||
href: externalLinks.x,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.instagram', locale),
|
||||
href: externalLinks.instagram,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('nav.company', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/customer-story-card.jpg',
|
||||
imageAlt: t('nav.featuredCompanyAlt', locale),
|
||||
title: t('nav.featuredCompanyTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.watchNow', locale),
|
||||
ariaLabel: t('nav.featuredCompanyCtaAria', locale),
|
||||
href: '/customers#hero-video'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('nav.company', locale),
|
||||
items: [
|
||||
{ label: t('nav.aboutUs', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.contact', locale), href: routes.contact }
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colMore', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.customerStories', locale),
|
||||
href: routes.customers
|
||||
},
|
||||
// TODO: no /brand page yet
|
||||
// { label: t('nav.brand', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,14 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
},
|
||||
'cta.watchDemo': {
|
||||
en: 'Watch Demo',
|
||||
'zh-CN': '观看演示'
|
||||
},
|
||||
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
@@ -1843,10 +1851,69 @@ const translations = {
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
|
||||
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
|
||||
'nav.ctaDesktopCore': { en: 'DESKTOP', 'zh-CN': '桌面版' },
|
||||
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
|
||||
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
|
||||
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
|
||||
'nav.mobileMenuDescription': {
|
||||
en: 'Site navigation and quick links',
|
||||
'zh-CN': '网站导航和快速链接'
|
||||
},
|
||||
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
|
||||
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
|
||||
// Column headers used in HeaderMainDesktop dropdowns
|
||||
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
|
||||
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
|
||||
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
|
||||
'nav.colMore': { en: 'More', 'zh-CN': '更多' },
|
||||
// Dropdown items not yet covered above
|
||||
'nav.reddit': { en: 'Reddit', 'zh-CN': 'Reddit' },
|
||||
'nav.x': { en: 'X', 'zh-CN': 'X' },
|
||||
'nav.instagram': { en: 'Instagram', 'zh-CN': 'Instagram' },
|
||||
'nav.affiliates': { en: 'Affiliates', 'zh-CN': '联盟计划' },
|
||||
'nav.contact': { en: 'Contact', 'zh-CN': '联系我们' },
|
||||
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
|
||||
// so the copy can be swapped without renaming the key.
|
||||
'nav.featuredProductsTitle': {
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
'zh-CN': '天空替换'
|
||||
},
|
||||
'nav.featuredCommunityAlt': {
|
||||
en: 'Sky Replacement workflow demo image',
|
||||
'zh-CN': '天空替换工作流演示图片'
|
||||
},
|
||||
'nav.featuredCommunityCtaAria': {
|
||||
en: 'Watch the Sky Replacement demo',
|
||||
'zh-CN': '观看天空替换演示'
|
||||
},
|
||||
'nav.featuredCompanyTitle': {
|
||||
en: 'Customer story: Black Math',
|
||||
'zh-CN': '客户故事:Black Math'
|
||||
},
|
||||
'nav.featuredCompanyAlt': {
|
||||
en: 'Black Math customer story image',
|
||||
'zh-CN': 'Black Math 客户故事图片'
|
||||
},
|
||||
'nav.featuredCompanyCtaAria': {
|
||||
en: 'Watch the Black Math customer story',
|
||||
'zh-CN': '观看 Black Math 客户故事'
|
||||
},
|
||||
|
||||
// SiteFooter
|
||||
'footer.tagline': {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Analytics from '@vercel/analytics/astro'
|
||||
import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import SiteNav from '../components/common/SiteNav.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
@@ -137,7 +137,7 @@ const websiteJsonLd = {
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
<SiteNav locale={locale} github-stars={githubStars} client:load />
|
||||
<HeaderMain locale={locale} github-stars={githubStars} client:load />
|
||||
<main class="mt-20 lg:mt-32">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ProductCardsSection from '../components/home/ProductCardsSection.vue'
|
||||
import HeroSection from '../components/home/HeroSection.vue'
|
||||
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
import UseCaseSection from '../components/home/UseCaseSection.vue'
|
||||
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
|
||||
import GetStartedSection from '../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import ProductCardsSection from "../components/home/ProductCardsSection.vue";
|
||||
import HeroSection from "../components/home/HeroSection.vue";
|
||||
import SocialProofBarSection from "../components/common/SocialProofBarSection.vue";
|
||||
import ProductShowcaseSection from "../components/home/ProductShowcaseSection.vue";
|
||||
import UseCaseSection from "../components/home/UseCaseSection.vue";
|
||||
import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSection.vue";
|
||||
import GetStartedSection from "../components/home/GetStartedSection.vue";
|
||||
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
|
||||
import { t } from "../i18n/translations";
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
description={t('hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfy ui application', 'comfyui application', 'comfy app', 'comfyui', 'visual ai app', 'node-based ai', 'generative ai workflows']}
|
||||
description={t("hero.subtitle", "en")}
|
||||
keywords={[
|
||||
"comfyui app",
|
||||
"comfyui web app",
|
||||
"comfy ui application",
|
||||
"comfyui application",
|
||||
"comfy app",
|
||||
"comfyui",
|
||||
"visual ai app",
|
||||
"node-based ai",
|
||||
"generative ai workflows",
|
||||
]}
|
||||
>
|
||||
<HeroSection client:load />
|
||||
<SocialProofBarSection />
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
@import 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
|
||||
|
||||
@import 'tailwindcss';
|
||||
@import '@comfyorg/design-system/css/base.css';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
/* PP Formula's native vertical metrics place the baseline too high, so caps
|
||||
sit in the upper half of the line box. Overriding ascent/descent re-anchors
|
||||
the baseline so caps render optically centered when line-height is tight.
|
||||
BUT, IT IS NOT WELL SUPPORTED:
|
||||
ascent-override: 92%;
|
||||
descent-override: 8%;
|
||||
line-gap-override: 0%; */
|
||||
@font-face {
|
||||
font-family: 'PP Formula';
|
||||
src: url('/fonts/PPFormula-Light.woff2') format('woff2');
|
||||
@@ -53,10 +63,12 @@
|
||||
--color-site-dropdown: #332b38;
|
||||
--color-primary-comfy-yellow: #f2ff59;
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-ink-light: #2a2330;
|
||||
--color-primary-comfy-canvas: #c2bfb9;
|
||||
--color-primary-warm-white: #f0efed;
|
||||
--color-primary-warm-gray: #7e7c78;
|
||||
--color-secondary-mauve: #4d3762;
|
||||
--color-destructive: #f44336;
|
||||
--color-primary-comfy-plum: #49378b;
|
||||
--color-secondary-cool-gray: #3c3c3c;
|
||||
--color-illustration-forest: #20464c;
|
||||
@@ -100,6 +112,7 @@
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
@@ -109,6 +122,7 @@
|
||||
0% {
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
@@ -131,9 +145,11 @@
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.75);
|
||||
opacity: 0;
|
||||
@@ -174,6 +190,7 @@
|
||||
@utility scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,25 @@ test.describe(
|
||||
.poll(() => collectWidgetLabels(shownSection))
|
||||
.not.toContain('text')
|
||||
})
|
||||
|
||||
test(
|
||||
'control after generate does not display',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
await editor.ensureOpen(subgraphNode)
|
||||
|
||||
await expect(
|
||||
editor.promotionItems.first(),
|
||||
'some promotion item is visible'
|
||||
).toBeVisible()
|
||||
|
||||
const widgetName = 'control_after_generate'
|
||||
await expect(editor.resolveItem({ widgetName })).toBeHidden()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Parameters tab (WidgetActions menu)', () => {
|
||||
|
||||
68
browser_tests/tests/subscriptionPaywallError.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
// Regression for #12840: a free-tier paywall on queue (`POST /prompt` 402 with
|
||||
// `{ error: { type: 'PAYMENT_REQUIRED', message: 'Subscription required to
|
||||
// queue workflows' } }`) is an account precondition. It must open its own modal
|
||||
// and stay out of the error panel, instead of surfacing the raw backend string
|
||||
// with non-actionable Find-on-GitHub / Copy actions.
|
||||
test.describe('Subscription paywall on queue', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
async function mockQueueError(page: Page, error: PromptResponse['error']) {
|
||||
const body: PromptResponse = { node_errors: {}, error }
|
||||
await page.route('**/api/prompt', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 402,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('keeps the subscription paywall out of the error panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await mockQueueError(comfyPage.page, {
|
||||
type: 'PAYMENT_REQUIRED',
|
||||
message: 'Subscription required to queue workflows',
|
||||
details: ''
|
||||
})
|
||||
|
||||
const queued = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
await comfyPage.actionbar.queueButton.primaryButton.click()
|
||||
await queued
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('still surfaces ordinary queue errors in the error panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await mockQueueError(comfyPage.page, {
|
||||
type: 'server_error',
|
||||
message: 'The server exploded',
|
||||
details: ''
|
||||
})
|
||||
|
||||
const queued = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
await comfyPage.actionbar.queueButton.primaryButton.click()
|
||||
await queued
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@@ -420,7 +420,6 @@ export default defineConfig([
|
||||
'@intlify/vue-i18n/no-raw-text': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// i18n import enforcement
|
||||
// Vue components must use the useI18n() composable, not the global t/d/st/te
|
||||
{
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "catalog:",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-desktop-bridge-types": "workspace:*",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/fbx-exporter-three": "^1.0.1",
|
||||
|
||||
91
packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
export interface ComfyDownloadProgress {
|
||||
url: string
|
||||
filename: string
|
||||
directory?: string
|
||||
progress: number
|
||||
receivedBytes?: number
|
||||
totalBytes?: number
|
||||
speedBytesPerSec?: number
|
||||
etaSeconds?: number
|
||||
status:
|
||||
| 'pending'
|
||||
| 'downloading'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'cancelled'
|
||||
error?: string
|
||||
isImage?: boolean
|
||||
}
|
||||
|
||||
export interface TerminalRestore {
|
||||
buffer: string[]
|
||||
size: { cols: number; rows: number }
|
||||
exited: boolean
|
||||
}
|
||||
|
||||
export interface LogsRestore {
|
||||
installationId: string
|
||||
buffer: string[]
|
||||
}
|
||||
|
||||
export interface LogsOutputMsg {
|
||||
installationId: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type ComfyDesktop2TelemetryValue = string | number | boolean | null
|
||||
export type ComfyDesktop2TelemetryProperties = Record<
|
||||
string,
|
||||
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
|
||||
>
|
||||
|
||||
export interface ComfyDesktop2TerminalBridge {
|
||||
subscribe(installationId?: string): Promise<TerminalRestore>
|
||||
unsubscribe(installationId?: string): Promise<void>
|
||||
write(data: string, installationId?: string): Promise<void>
|
||||
resize(cols: number, rows: number, installationId?: string): Promise<void>
|
||||
restart(installationId?: string): Promise<TerminalRestore>
|
||||
openPopout(): Promise<void>
|
||||
onOutput(callback: (data: string) => void): () => void
|
||||
onExited(callback: () => void): () => void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2LogsBridge {
|
||||
subscribe(installationId?: string): Promise<LogsRestore>
|
||||
unsubscribe(installationId?: string): Promise<void>
|
||||
openPopout(): Promise<void>
|
||||
onOutput(callback: (msg: LogsOutputMsg) => void): () => void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2TelemetryBridge {
|
||||
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2Bridge {
|
||||
isRemote(): boolean
|
||||
downloadModel?: (
|
||||
url: string,
|
||||
filename: string,
|
||||
directory: string
|
||||
) => Promise<boolean>
|
||||
downloadAsset?: (
|
||||
url: string,
|
||||
filename: string,
|
||||
authToken?: string
|
||||
) => Promise<boolean>
|
||||
pauseDownload?: (url: string) => Promise<boolean>
|
||||
resumeDownload?: (url: string) => Promise<boolean>
|
||||
cancelDownload?: (url: string) => Promise<boolean>
|
||||
onDownloadProgress?: (
|
||||
callback: (data: ComfyDownloadProgress) => void
|
||||
) => () => void
|
||||
reportTheme?: (bg: string, text: string) => void
|
||||
Terminal?: ComfyDesktop2TerminalBridge
|
||||
Logs?: ComfyDesktop2LogsBridge
|
||||
Telemetry?: ComfyDesktop2TelemetryBridge
|
||||
}
|
||||
|
||||
export type ComfyDesktop2BridgeImplementation = {
|
||||
[K in keyof ComfyDesktop2Bridge]-?: NonNullable<ComfyDesktop2Bridge[K]>
|
||||
}
|
||||
1
packages/comfyui-desktop-bridge-types/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from './comfyDesktopBridge.js'
|
||||
1
packages/comfyui-desktop-bridge-types/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export {}
|
||||
27
packages/comfyui-desktop-bridge-types/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-desktop-bridge-types",
|
||||
"version": "0.1.2",
|
||||
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
|
||||
"homepage": "https://comfy.org",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Comfy Org",
|
||||
"email": "support@comfy.org",
|
||||
"url": "https://www.comfy.org"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Comfy-Org/ComfyUI_frontend.git"
|
||||
},
|
||||
"files": [
|
||||
"comfyDesktopBridge.d.ts",
|
||||
"index.d.ts",
|
||||
"index.js"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
39
pnpm-lock.yaml
generated
@@ -51,6 +51,9 @@ catalogs:
|
||||
'@lobehub/i18n-cli':
|
||||
specifier: ^1.26.1
|
||||
version: 1.26.1
|
||||
'@lucide/vue':
|
||||
specifier: ^1.17.0
|
||||
version: 1.18.0
|
||||
'@pinia/testing':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
@@ -186,6 +189,9 @@ catalogs:
|
||||
axios:
|
||||
specifier: ^1.15.2
|
||||
version: 1.16.1
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
cross-env:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
@@ -429,6 +435,9 @@ importers:
|
||||
'@atlaskit/pragmatic-drag-and-drop':
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
'@comfyorg/comfyui-desktop-bridge-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/comfyui-desktop-bridge-types
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 'catalog:'
|
||||
version: 0.6.2
|
||||
@@ -947,12 +956,18 @@ importers:
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/tailwind-utils
|
||||
'@lucide/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 1.18.0(vue@3.5.34(typescript@5.9.3))
|
||||
'@vercel/analytics':
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.0(vue@3.5.34(typescript@5.9.3))
|
||||
class-variance-authority:
|
||||
specifier: 'catalog:'
|
||||
version: 0.7.1
|
||||
cva:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.0-beta.4(typescript@5.9.3)
|
||||
@@ -965,6 +980,9 @@ importers:
|
||||
posthog-js:
|
||||
specifier: 'catalog:'
|
||||
version: 1.358.1
|
||||
reka-ui:
|
||||
specifier: 'catalog:'
|
||||
version: 2.5.0(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
three:
|
||||
specifier: 'catalog:'
|
||||
version: 0.184.0
|
||||
@@ -996,6 +1014,9 @@ importers:
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.19.4
|
||||
tw-animate-css:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.8
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
@@ -1003,6 +1024,8 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/coverage-v8@4.0.16(vitest@4.1.8))(@vitest/ui@4.0.16(vitest@4.1.8))(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
|
||||
packages/comfyui-desktop-bridge-types: {}
|
||||
|
||||
packages/design-system:
|
||||
dependencies:
|
||||
'@iconify-json/lucide':
|
||||
@@ -2397,6 +2420,11 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@lucide/vue@1.18.0':
|
||||
resolution: {integrity: sha512-DmnUpDB85PlMZ+ofjZLcKq3JoJnaD1bk7SIj9xwUvqerfNqA6hCLa0/m3gIybH6rdrErABbqvTD8yYJdNqiZ3Q==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
'@lukeed/csprng@1.1.0':
|
||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4852,6 +4880,9 @@ packages:
|
||||
citty@0.2.1:
|
||||
resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
clean-css@5.3.3:
|
||||
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
|
||||
engines: {node: '>= 10.0'}
|
||||
@@ -10535,6 +10566,10 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@lucide/vue@1.18.0(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
|
||||
'@lukeed/csprng@1.1.0': {}
|
||||
|
||||
'@lukeed/uuid@2.0.1':
|
||||
@@ -13011,6 +13046,10 @@ snapshots:
|
||||
|
||||
citty@0.2.1: {}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
clean-css@5.3.3:
|
||||
dependencies:
|
||||
source-map: 0.6.1
|
||||
|
||||
@@ -25,6 +25,7 @@ catalog:
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@lucide/vue': ^1.17.0
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -71,6 +72,7 @@ catalog:
|
||||
algoliasearch: ^5.21.0
|
||||
astro: ^6.4.2
|
||||
axios: ^1.15.2
|
||||
class-variance-authority: ^0.7.1
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dompurify: ^3.4.5
|
||||
@@ -165,7 +167,7 @@ overrides:
|
||||
vite: 'catalog:'
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
#Security overrides
|
||||
# Security overrides
|
||||
lodash: ^4.18.0
|
||||
yaml: ^2.8.3
|
||||
minimatch@^9.0.0: ^9.0.7
|
||||
|
||||
@@ -2,6 +2,12 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const mainPackage = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
|
||||
const desktopBridgeTypesPackage = JSON.parse(
|
||||
fs.readFileSync(
|
||||
'./packages/comfyui-desktop-bridge-types/package.json',
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
|
||||
// Create the types-only package.json
|
||||
const typesPackage = {
|
||||
@@ -16,7 +22,9 @@ const typesPackage = {
|
||||
homepage: mainPackage.homepage,
|
||||
description: `TypeScript definitions for ${mainPackage.name}`,
|
||||
license: mainPackage.license,
|
||||
dependencies: {},
|
||||
dependencies: {
|
||||
'@comfyorg/comfyui-desktop-bridge-types': desktopBridgeTypesPackage.version
|
||||
},
|
||||
peerDependencies: {
|
||||
vue: mainPackage.dependencies.vue,
|
||||
zod: mainPackage.dependencies.zod
|
||||
@@ -34,5 +42,3 @@ fs.writeFileSync(
|
||||
path.join(distDir, 'package.json'),
|
||||
JSON.stringify(typesPackage, null, 2)
|
||||
)
|
||||
|
||||
console.log('Types package.json have been prepared in the dist directory')
|
||||
|
||||
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
@@ -53,6 +54,7 @@ const canvasStore = useCanvasStore()
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
@@ -175,9 +177,13 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
const promotedSourceKeys = new Set(activeRows.value.map(activeRowSourceKey))
|
||||
return interiorWidgets.value.filter(
|
||||
([n, w]) => !promotedSourceKeys.has(`${n.id}:${w.name}`)
|
||||
)
|
||||
return interiorWidgets.value
|
||||
.filter(([n, w]) => !promotedSourceKeys.has(`${n.id}:${w.name}`))
|
||||
.filter(
|
||||
([, w]) =>
|
||||
w.name.startsWith('$$') ||
|
||||
!(w.options.canvasOnly && shouldRenderVueNodes.value)
|
||||
)
|
||||
})
|
||||
const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
export type AppMode =
|
||||
| 'graph'
|
||||
| 'app'
|
||||
| 'builder:inputs'
|
||||
| 'builder:outputs'
|
||||
| 'builder:arrange'
|
||||
|
||||
type WorkflowModeSource = {
|
||||
activeMode: AppMode | null
|
||||
initialMode: AppMode | null | undefined
|
||||
}
|
||||
|
||||
export function getWorkflowMode(
|
||||
workflow: WorkflowModeSource | null | undefined
|
||||
): AppMode {
|
||||
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
|
||||
}
|
||||
|
||||
export function isAppModeValue(mode: AppMode): boolean {
|
||||
return mode === 'app' || mode === 'builder:arrange'
|
||||
}
|
||||
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
const enableAppBuilder = ref(true)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG
|
||||
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
|
||||
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
|
||||
import {
|
||||
DEFAULT_DARK_COLOR_PALETTE,
|
||||
DEFAULT_LIGHT_COLOR_PALETTE
|
||||
@@ -85,6 +86,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const executionStore = useExecutionStore()
|
||||
const modelStore = useModelStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { trackRunButton } = useRunButtonTelemetry()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -499,7 +501,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
@@ -522,7 +524,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
@@ -544,7 +546,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
|
||||
112
src/composables/useRunButtonTelemetry.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
mode: { value: 'graph' },
|
||||
isAppMode: { value: false },
|
||||
telemetry: {
|
||||
trackRunButton: vi.fn()
|
||||
},
|
||||
executionContext: {
|
||||
is_template: false,
|
||||
workflow_name: 'Desktop workflow',
|
||||
custom_node_count: 2,
|
||||
total_node_count: 4,
|
||||
subgraph_count: 1,
|
||||
has_api_nodes: true,
|
||||
api_node_names: ['LoadImage'],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: []
|
||||
},
|
||||
executionContextError: null as Error | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: state.mode,
|
||||
isAppMode: state.isAppMode
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => state.telemetry
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
|
||||
getExecutionContext: () => {
|
||||
if (state.executionContextError) throw state.executionContextError
|
||||
return state.executionContext
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
getRunButtonTelemetryProperties,
|
||||
useRunButtonTelemetry
|
||||
} from './useRunButtonTelemetry'
|
||||
|
||||
describe('useRunButtonTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
state.telemetry.trackRunButton.mockClear()
|
||||
state.mode.value = 'graph'
|
||||
state.isAppMode.value = false
|
||||
state.executionContextError = null
|
||||
})
|
||||
|
||||
it('builds run button properties from workspace state', () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
|
||||
expect(
|
||||
getRunButtonTelemetryProperties({
|
||||
subscribe_to_run: true,
|
||||
trigger_source: 'button'
|
||||
})
|
||||
).toEqual({
|
||||
subscribe_to_run: true,
|
||||
workflow_type: 'custom',
|
||||
workflow_name: 'Desktop workflow',
|
||||
custom_node_count: 2,
|
||||
total_node_count: 4,
|
||||
subgraph_count: 1,
|
||||
has_api_nodes: true,
|
||||
api_node_names: ['LoadImage'],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: [],
|
||||
trigger_source: 'button',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false,
|
||||
dock_state: 'floating'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks the completed run button payload', () => {
|
||||
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
|
||||
|
||||
expect(state.telemetry.trackRunButton).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'linear',
|
||||
workflow_name: 'Desktop workflow'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not throw when run button context collection fails', () => {
|
||||
const error = new Error('Context unavailable')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
state.executionContextError = error
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
|
||||
).not.toThrow()
|
||||
|
||||
expect(state.telemetry.trackRunButton).not.toHaveBeenCalled()
|
||||
expect(consoleError).toHaveBeenCalledExactlyOnceWith(
|
||||
'[Telemetry] Run button tracking failed',
|
||||
error
|
||||
)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
52
src/composables/useRunButtonTelemetry.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
ExecutionTriggerSource,
|
||||
RunButtonProperties
|
||||
} from '@/platform/telemetry/types'
|
||||
import { getActionbarDockState } from '@/platform/telemetry/utils/getActionbarDockState'
|
||||
import { getExecutionContext } from '@/platform/telemetry/utils/getExecutionContext'
|
||||
|
||||
export type RunButtonTelemetryOptions = {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}
|
||||
|
||||
export function getRunButtonTelemetryProperties(
|
||||
options?: RunButtonTelemetryOptions
|
||||
): RunButtonProperties {
|
||||
const executionContext = getExecutionContext()
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
return {
|
||||
subscribe_to_run: options?.subscribe_to_run ?? false,
|
||||
workflow_type: executionContext.is_template ? 'template' : 'custom',
|
||||
workflow_name: executionContext.workflow_name ?? 'untitled',
|
||||
custom_node_count: executionContext.custom_node_count,
|
||||
total_node_count: executionContext.total_node_count,
|
||||
subgraph_count: executionContext.subgraph_count,
|
||||
has_api_nodes: executionContext.has_api_nodes,
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value,
|
||||
dock_state: getActionbarDockState()
|
||||
}
|
||||
}
|
||||
|
||||
export function useRunButtonTelemetry() {
|
||||
function trackRunButton(options?: RunButtonTelemetryOptions): void {
|
||||
const telemetry = useTelemetry()
|
||||
if (!telemetry) return
|
||||
|
||||
try {
|
||||
telemetry.trackRunButton(getRunButtonTelemetryProperties(options))
|
||||
} catch (error) {
|
||||
console.error('[Telemetry] Run button tracking failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
return { trackRunButton }
|
||||
}
|
||||
16
src/main.ts
@@ -31,21 +31,27 @@ import App from './App.vue'
|
||||
import './assets/css/style.css'
|
||||
import { i18n } from './i18n'
|
||||
|
||||
/**
|
||||
* CRITICAL: Load remote config FIRST for cloud builds to ensure
|
||||
* window.__CONFIG__is available for all modules during initialization
|
||||
*/
|
||||
const isCloud = __DISTRIBUTION__ === 'cloud'
|
||||
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
|
||||
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
|
||||
|
||||
if (isCloud) {
|
||||
if (requiresRemoteConfigBootstrap) {
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
}
|
||||
|
||||
if (isCloud) {
|
||||
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
|
||||
await initTelemetry()
|
||||
}
|
||||
|
||||
if (hasHostTelemetryBridge) {
|
||||
const { initHostTelemetry } =
|
||||
await import('@/platform/telemetry/initHostTelemetry')
|
||||
initHostTelemetry()
|
||||
}
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
semantic: {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
|
||||
@@ -22,8 +22,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -32,6 +32,7 @@ const isMdOrLarger = breakpoints.greaterOrEqual('md')
|
||||
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
const { trackRunButton } = useRunButtonTelemetry()
|
||||
|
||||
const canResubscribe = computed(() => permissions.value.canManageSubscription)
|
||||
|
||||
@@ -50,7 +51,7 @@ const buttonTooltip = computed(() =>
|
||||
|
||||
function handleSubscribeToRun() {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
|
||||
trackRunButton({ subscribe_to_run: true })
|
||||
}
|
||||
|
||||
showSubscriptionDialog()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAccountPreconditionDialog } from './useAccountPreconditionDialog'
|
||||
|
||||
const mockDialogService = {
|
||||
showApiNodesSignInDialog: vi.fn(),
|
||||
showSubscriptionRequiredDialog: vi.fn(),
|
||||
showTopUpCreditsDialog: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => mockDialogService)
|
||||
}))
|
||||
|
||||
describe('useAccountPreconditionDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('routes a sign-in precondition to the API sign-in dialog with the node type', () => {
|
||||
useAccountPreconditionDialog().open('sign_in', { nodeType: 'ApiNode' })
|
||||
|
||||
expect(mockDialogService.showApiNodesSignInDialog).toHaveBeenCalledWith([
|
||||
'ApiNode'
|
||||
])
|
||||
expect(
|
||||
mockDialogService.showSubscriptionRequiredDialog
|
||||
).not.toHaveBeenCalled()
|
||||
expect(mockDialogService.showTopUpCreditsDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a sign-in precondition with no node type to an empty list', () => {
|
||||
useAccountPreconditionDialog().open('sign_in')
|
||||
|
||||
expect(mockDialogService.showApiNodesSignInDialog).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('routes a subscription precondition to the subscription dialog', () => {
|
||||
useAccountPreconditionDialog().open('subscription')
|
||||
|
||||
expect(
|
||||
mockDialogService.showSubscriptionRequiredDialog
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(mockDialogService.showApiNodesSignInDialog).not.toHaveBeenCalled()
|
||||
expect(mockDialogService.showTopUpCreditsDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a credit precondition to the top-up dialog', () => {
|
||||
useAccountPreconditionDialog().open('credits', { nodeType: 'PartnerNode' })
|
||||
|
||||
expect(mockDialogService.showTopUpCreditsDialog).toHaveBeenCalledWith({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
expect(
|
||||
mockDialogService.showSubscriptionRequiredDialog
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
export interface AccountPreconditionContext {
|
||||
/** Node type that triggered the precondition, used as modal context. */
|
||||
nodeType?: string
|
||||
}
|
||||
|
||||
// Routes a resolved account precondition to its dedicated modal. This is the
|
||||
// single seam where FE-978 attaches role-aware (member vs owner) subscription
|
||||
// content: the `subscription` branch resolves to the subscription dialog, whose
|
||||
// inner content FE-978 specializes for cancelled/inactive team states.
|
||||
export function useAccountPreconditionDialog() {
|
||||
const dialogService = useDialogService()
|
||||
|
||||
function open(
|
||||
precondition: AccountPrecondition,
|
||||
context: AccountPreconditionContext = {}
|
||||
): void {
|
||||
switch (precondition) {
|
||||
case 'sign_in':
|
||||
void dialogService.showApiNodesSignInDialog(
|
||||
context.nodeType ? [context.nodeType] : []
|
||||
)
|
||||
return
|
||||
case 'subscription':
|
||||
void dialogService.showSubscriptionRequiredDialog()
|
||||
return
|
||||
case 'credits':
|
||||
void dialogService.showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return { open }
|
||||
}
|
||||
114
src/platform/errorCatalog/accountPreconditionRouting.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isAccountPreconditionCatalogId,
|
||||
preconditionForCatalogId,
|
||||
resolveAccountPrecondition
|
||||
} from './accountPreconditionRouting'
|
||||
import {
|
||||
EXECUTION_FAILED_CATALOG_ID,
|
||||
INSUFFICIENT_CREDITS_CATALOG_ID,
|
||||
SIGN_IN_REQUIRED_CATALOG_ID,
|
||||
SUBSCRIPTION_REQUIRED_CATALOG_ID,
|
||||
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
|
||||
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
|
||||
} from './catalogIds'
|
||||
|
||||
describe('resolveAccountPrecondition', () => {
|
||||
it('classifies a sign-in error', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'Unauthorized: Please login first to use this node.'
|
||||
})
|
||||
).toBe('sign_in')
|
||||
})
|
||||
|
||||
it('classifies an inactive-subscription error', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'InactiveSubscriptionError',
|
||||
exceptionMessage:
|
||||
'User has no active subscription. Please subscribe to a plan to continue.'
|
||||
})
|
||||
).toBe('subscription')
|
||||
})
|
||||
|
||||
it('classifies the queue paywall (PAYMENT_REQUIRED) as a subscription precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'PAYMENT_REQUIRED',
|
||||
exceptionMessage: 'Subscription required to queue workflows'
|
||||
})
|
||||
).toBe('subscription')
|
||||
})
|
||||
|
||||
it('classifies a subscription-upgrade error as a subscription precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage:
|
||||
'the following private models require a subscription upgrade: flux-pro'
|
||||
})
|
||||
).toBe('subscription')
|
||||
})
|
||||
|
||||
it('classifies an account credit error', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'InsufficientFundsError',
|
||||
exceptionMessage:
|
||||
'Payment Required: Please add credits to your account to use this node.'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('classifies a workspace credit error', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage:
|
||||
'Payment Required: Please add credits to your workspace to continue.'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('returns undefined for an ordinary workflow error', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'CUDA out of memory'
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('preconditionForCatalogId / isAccountPreconditionCatalogId', () => {
|
||||
it('maps every precondition catalog id', () => {
|
||||
expect(preconditionForCatalogId(SIGN_IN_REQUIRED_CATALOG_ID)).toBe(
|
||||
'sign_in'
|
||||
)
|
||||
expect(preconditionForCatalogId(SUBSCRIPTION_REQUIRED_CATALOG_ID)).toBe(
|
||||
'subscription'
|
||||
)
|
||||
expect(
|
||||
preconditionForCatalogId(SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID)
|
||||
).toBe('subscription')
|
||||
expect(preconditionForCatalogId(INSUFFICIENT_CREDITS_CATALOG_ID)).toBe(
|
||||
'credits'
|
||||
)
|
||||
expect(
|
||||
preconditionForCatalogId(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('does not treat a workflow error catalog id as a precondition', () => {
|
||||
expect(
|
||||
preconditionForCatalogId(EXECUTION_FAILED_CATALOG_ID)
|
||||
).toBeUndefined()
|
||||
expect(isAccountPreconditionCatalogId(EXECUTION_FAILED_CATALOG_ID)).toBe(
|
||||
false
|
||||
)
|
||||
expect(isAccountPreconditionCatalogId(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
44
src/platform/errorCatalog/accountPreconditionRouting.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
INSUFFICIENT_CREDITS_CATALOG_ID,
|
||||
SIGN_IN_REQUIRED_CATALOG_ID,
|
||||
SUBSCRIPTION_REQUIRED_CATALOG_ID,
|
||||
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
|
||||
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
|
||||
} from './catalogIds'
|
||||
import type { RuntimeErrorInfo } from './runtimeErrorMatcher'
|
||||
import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher'
|
||||
|
||||
// Account preconditions are gating states (sign-in, subscription, credits) that
|
||||
// must open their own modal instead of surfacing as a workflow error. They are
|
||||
// excluded from the error panel and the error count.
|
||||
export type AccountPrecondition = 'sign_in' | 'subscription' | 'credits'
|
||||
|
||||
const CATALOG_ID_TO_PRECONDITION = new Map<string, AccountPrecondition>([
|
||||
[SIGN_IN_REQUIRED_CATALOG_ID, 'sign_in'],
|
||||
[SUBSCRIPTION_REQUIRED_CATALOG_ID, 'subscription'],
|
||||
[SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, 'subscription'],
|
||||
[INSUFFICIENT_CREDITS_CATALOG_ID, 'credits'],
|
||||
[WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID, 'credits']
|
||||
])
|
||||
|
||||
export function preconditionForCatalogId(
|
||||
catalogId: string | undefined
|
||||
): AccountPrecondition | undefined {
|
||||
if (!catalogId) return undefined
|
||||
return CATALOG_ID_TO_PRECONDITION.get(catalogId)
|
||||
}
|
||||
|
||||
export function isAccountPreconditionCatalogId(
|
||||
catalogId: string | undefined
|
||||
): boolean {
|
||||
return preconditionForCatalogId(catalogId) !== undefined
|
||||
}
|
||||
|
||||
// Classifies a single runtime error payload into the account precondition it
|
||||
// represents, or `undefined` when it is an ordinary workflow error.
|
||||
export function resolveAccountPrecondition(
|
||||
info: RuntimeErrorInfo
|
||||
): AccountPrecondition | undefined {
|
||||
const match = resolveRuntimeCatalogMatch(info)
|
||||
return preconditionForCatalogId(match?.catalogId)
|
||||
}
|
||||
@@ -37,7 +37,8 @@ const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
|
||||
])
|
||||
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
|
||||
'Workspace has no active subscription. Please subscribe to a plan to continue.',
|
||||
'User has no active subscription. Please subscribe to a plan to continue.'
|
||||
'User has no active subscription. Please subscribe to a plan to continue.',
|
||||
'Subscription required to queue workflows'
|
||||
])
|
||||
const SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX =
|
||||
'the following private models require a subscription upgrade:'
|
||||
@@ -120,7 +121,7 @@ const PREPROCESSING_FAILED_PREFIXES = [
|
||||
'Failed to complete preparation:'
|
||||
]
|
||||
|
||||
interface RuntimeErrorInfo {
|
||||
export interface RuntimeErrorInfo {
|
||||
exceptionType: string
|
||||
exceptionMessage: string
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
delete window.__comfyDesktop2
|
||||
delete window.__comfyDesktop2Remote
|
||||
})
|
||||
|
||||
describe('fetchModelMetadata', () => {
|
||||
@@ -258,7 +257,10 @@ describe('downloadModel', () => {
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
downloadModel: desktopDownloadModel
|
||||
}
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
@@ -289,7 +291,10 @@ describe('downloadModel', () => {
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockRejectedValue(bridgeError)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
downloadModel: desktopDownloadModel
|
||||
}
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
@@ -323,7 +328,10 @@ describe('downloadModel', () => {
|
||||
.mockImplementation(() => {
|
||||
throw bridgeError
|
||||
})
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
downloadModel: desktopDownloadModel
|
||||
}
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
@@ -353,8 +361,10 @@ describe('downloadModel', () => {
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
window.__comfyDesktop2Remote = true
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => true,
|
||||
downloadModel: desktopDownloadModel
|
||||
}
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
|
||||
@@ -2,21 +2,7 @@ import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
interface ComfyDesktop2Bridge {
|
||||
downloadModel: (
|
||||
url: string,
|
||||
filename: string,
|
||||
directory: string
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__comfyDesktop2?: ComfyDesktop2Bridge
|
||||
__comfyDesktop2Remote?: boolean
|
||||
}
|
||||
}
|
||||
import type { ComfyDesktop2Bridge } from '@/types'
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
@@ -55,7 +41,7 @@ async function startDesktop2ModelDownload(
|
||||
model: ModelWithUrl
|
||||
): Promise<void> {
|
||||
try {
|
||||
await bridge.downloadModel(model.url, model.name, model.directory)
|
||||
await bridge.downloadModel?.(model.url, model.name, model.directory)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to start Desktop2 model download:', error)
|
||||
}
|
||||
@@ -90,7 +76,7 @@ export function downloadModel(
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
const desktop2Bridge = window.__comfyDesktop2
|
||||
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
|
||||
if (desktop2Bridge?.downloadModel && !desktop2Bridge.isRemote()) {
|
||||
void startDesktop2ModelDownload(desktop2Bridge, model)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
@@ -14,6 +12,13 @@ interface RefreshRemoteConfigOptions {
|
||||
useAuth?: boolean
|
||||
}
|
||||
|
||||
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
|
||||
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
|
||||
|
||||
const { api } = await import('@/scripts/api')
|
||||
return api.fetchApi('/features', { cache: 'no-store' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads remote configuration from the backend /features endpoint
|
||||
* and updates the reactive remoteConfig ref.
|
||||
@@ -29,9 +34,7 @@ export async function refreshRemoteConfig(
|
||||
const { useAuth = true } = options
|
||||
|
||||
try {
|
||||
const response = useAuth
|
||||
? await api.fetchApi('/features', { cache: 'no-store' })
|
||||
: await fetch('/api/features', { cache: 'no-store' })
|
||||
const response = await fetchRemoteConfig(useAuth)
|
||||
|
||||
if (response.ok) {
|
||||
const config = await response.json()
|
||||
|
||||
@@ -94,6 +94,7 @@ export type RemoteConfig = {
|
||||
comfy_platform_base_url?: string
|
||||
firebase_config?: FirebaseRuntimeConfig
|
||||
telemetry_disabled_events?: TelemetryEventName[]
|
||||
enable_telemetry?: boolean
|
||||
model_upload_button_enabled?: boolean
|
||||
asset_rename_enabled?: boolean
|
||||
private_models_enabled?: boolean
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
ShareLinkOpenedMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
@@ -19,6 +18,7 @@ import type {
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
ShellLayoutMetadata,
|
||||
@@ -112,11 +112,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
this.dispatch((provider) => provider.trackRunButton?.(options))
|
||||
trackRunButton(properties: RunButtonProperties): void {
|
||||
this.dispatch((provider) => provider.trackRunButton?.(properties))
|
||||
}
|
||||
|
||||
startTopupTracking(): void {
|
||||
|
||||
54
src/platform/telemetry/initHostTelemetry.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { setTelemetryRegistry, useTelemetry } from '@/platform/telemetry'
|
||||
import { initHostTelemetry } from '@/platform/telemetry/initHostTelemetry'
|
||||
import { TelemetryEvents } from '@/platform/telemetry/types'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
describe('initHostTelemetry', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
remoteConfig.value = {}
|
||||
setTelemetryRegistry(null)
|
||||
localStorage.clear()
|
||||
delete window.__comfyDesktop2
|
||||
})
|
||||
|
||||
it('leaves the registry untouched when enable_telemetry is on but the host Telemetry bridge is absent', () => {
|
||||
remoteConfig.value = { enable_telemetry: true }
|
||||
|
||||
initHostTelemetry()
|
||||
|
||||
expect(useTelemetry()).toBeNull()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the registry untouched when enable_telemetry is off', () => {
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
Telemetry: { capture: vi.fn() }
|
||||
}
|
||||
remoteConfig.value = { enable_telemetry: false }
|
||||
|
||||
initHostTelemetry()
|
||||
|
||||
expect(useTelemetry()).toBeNull()
|
||||
})
|
||||
|
||||
it('registers the host telemetry sink when enable_telemetry and the bridge are present', () => {
|
||||
const capture = vi.fn()
|
||||
window.__comfyDesktop2 = { isRemote: () => false, Telemetry: { capture } }
|
||||
remoteConfig.value = { enable_telemetry: true }
|
||||
|
||||
initHostTelemetry()
|
||||
useTelemetry()?.trackSignupOpened()
|
||||
|
||||
expect(capture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_SIGN_UP_OPENED,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
23
src/platform/telemetry/initHostTelemetry.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { setTelemetryRegistry } from './index'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
import { TelemetryRegistry } from './TelemetryRegistry'
|
||||
import { HostTelemetrySink } from './providers/host/HostTelemetrySink'
|
||||
|
||||
const ENABLE_TELEMETRY_FEATURE = 'enable_telemetry'
|
||||
|
||||
function isHostTelemetryEnabled(): boolean {
|
||||
const override = getDevOverride<boolean>(ENABLE_TELEMETRY_FEATURE)
|
||||
if (override !== undefined) return override
|
||||
|
||||
return remoteConfig.value.enable_telemetry === true
|
||||
}
|
||||
|
||||
export function initHostTelemetry(): void {
|
||||
if (!isHostTelemetryEnabled()) return
|
||||
if (!window.__comfyDesktop2?.Telemetry) return
|
||||
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(new HostTelemetrySink())
|
||||
setTelemetryRegistry(registry)
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: { value: 'app' },
|
||||
isAppMode: { value: true }
|
||||
})
|
||||
}))
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
|
||||
|
||||
@@ -192,8 +185,22 @@ describe('GtmTelemetryProvider', () => {
|
||||
|
||||
it('pushes run_workflow with trigger_source', () => {
|
||||
const provider = createInitializedProvider()
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
provider.trackRunButton({ trigger_source: 'button' })
|
||||
provider.trackRunButton({
|
||||
subscribe_to_run: false,
|
||||
workflow_type: 'custom',
|
||||
workflow_name: 'untitled',
|
||||
custom_node_count: 0,
|
||||
total_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: [],
|
||||
trigger_source: 'button',
|
||||
view_mode: 'app',
|
||||
is_app_mode: true,
|
||||
dock_state: 'floating'
|
||||
})
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'run_workflow',
|
||||
trigger_source: 'button',
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
EnterLinearMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
@@ -13,6 +12,7 @@ import type {
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
@@ -29,8 +29,7 @@ import type {
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { getActionbarDockState } from '../../utils/getActionbarDockState'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
/**
|
||||
* Google Tag Manager telemetry provider.
|
||||
@@ -154,7 +153,7 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
this.pushEvent('begin_checkout', metadata)
|
||||
this.pushEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata)
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
@@ -183,18 +182,13 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
trackRunButton(properties: RunButtonProperties): void {
|
||||
this.pushEvent('run_workflow', {
|
||||
subscribe_to_run: options?.subscribe_to_run ?? false,
|
||||
trigger_source: options?.trigger_source ?? 'unknown',
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value,
|
||||
dock_state: getActionbarDockState()
|
||||
subscribe_to_run: properties.subscribe_to_run,
|
||||
trigger_source: properties.trigger_source ?? 'unknown',
|
||||
view_mode: properties.view_mode,
|
||||
is_app_mode: properties.is_app_mode,
|
||||
dock_state: properties.dock_state
|
||||
})
|
||||
}
|
||||
|
||||
|
||||