mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-20 12:27:36 +00:00
Compare commits
12 Commits
fix-model-
...
test2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8d2b8fad2 | ||
|
|
b52b2bbc30 | ||
|
|
424bd21559 | ||
|
|
04286c033a | ||
|
|
59429cbe56 | ||
|
|
eb04178e33 | ||
|
|
b88d96d6cc | ||
|
|
dedc77786f | ||
|
|
356ebe538f | ||
|
|
2c06c58621 | ||
|
|
c13343b8fb | ||
|
|
ce4837a57c |
@@ -87,6 +87,8 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
},
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { PassThrough } from '@primevue/core'
|
||||
import Button from 'primevue/button'
|
||||
import Step, { type StepPassThroughOptions } from 'primevue/step'
|
||||
import Step from 'primevue/step'
|
||||
import type { StepPassThroughOptions } from 'primevue/step'
|
||||
import StepList from 'primevue/steplist'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -155,12 +155,14 @@ export async function loadLocale(locale: string): Promise<void> {
|
||||
}
|
||||
|
||||
// Only include English in the initial bundle
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings)
|
||||
}
|
||||
const enMessages = buildLocale(en, enNodes, enCommands, enSettings)
|
||||
|
||||
// Type for locale messages - inferred from the English locale structure
|
||||
type LocaleMessages = typeof messages.en
|
||||
type LocaleMessages = typeof enMessages
|
||||
|
||||
const messages: Record<string, LocaleMessages> = {
|
||||
en: enMessages
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { type Ref, computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.
|
||||
|
||||
@@ -29,7 +29,8 @@ import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Button from 'primevue/button'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { type DialogAction, getDialog } from '@/constants/desktopDialogs'
|
||||
import { getDialog } from '@/constants/desktopDialogs'
|
||||
import type { DialogAction } from '@/constants/desktopDialogs'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img
|
||||
class="sad-girl"
|
||||
src="/assets/images/sad_girl.png"
|
||||
alt="Sad girl illustration"
|
||||
:alt="$t('notSupported.illustrationAlt')"
|
||||
/>
|
||||
|
||||
<div class="no-drag sad-text flex items-center">
|
||||
|
||||
@@ -126,6 +126,20 @@ class ConfirmDialog {
|
||||
const loc = this[locator]
|
||||
await expect(loc).toBeVisible()
|
||||
await loc.click()
|
||||
|
||||
// Wait for the dialog mask to disappear after confirming
|
||||
const mask = this.page.locator('.p-dialog-mask')
|
||||
const count = await mask.count()
|
||||
if (count > 0) {
|
||||
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
|
||||
}
|
||||
|
||||
// Wait for workflow service to finish if it's busy
|
||||
await this.page.waitForFunction(
|
||||
() => window['app']?.extensionManager?.workflow?.isBusy === false,
|
||||
undefined,
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +256,9 @@ export class ComfyPage {
|
||||
await this.page.evaluate(async () => {
|
||||
await window['app'].extensionManager.workflow.syncWorkflows()
|
||||
})
|
||||
|
||||
// Wait for Vue to re-render the workflow list
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
|
||||
@@ -137,6 +137,13 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.click()
|
||||
await this.page.keyboard.type(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for workflow service to finish renaming
|
||||
await this.page.waitForFunction(
|
||||
() => !window['app']?.extensionManager?.workflow?.isBusy,
|
||||
undefined,
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
}
|
||||
|
||||
async insertWorkflow(locator: Locator) {
|
||||
|
||||
@@ -92,9 +92,26 @@ export class Topbar {
|
||||
)
|
||||
// Wait for the dialog to close.
|
||||
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
|
||||
|
||||
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
|
||||
// If so, return early to let the test handle the confirmation
|
||||
const confirmationDialog = this.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
if (await confirmationDialog.isVisible()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async openTopbarMenu() {
|
||||
// If menu is already open, close it first to reset state
|
||||
const isAlreadyOpen = await this.menuLocator.isVisible()
|
||||
if (isAlreadyOpen) {
|
||||
// Click outside the menu to close it properly
|
||||
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
|
||||
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
|
||||
}
|
||||
|
||||
await this.menuTrigger.click()
|
||||
await this.menuLocator.waitFor({ state: 'visible' })
|
||||
return this.menuLocator
|
||||
@@ -162,15 +179,36 @@ export class Topbar {
|
||||
|
||||
await topLevelMenu.hover()
|
||||
|
||||
// Hover over top-level menu with retry logic for flaky submenu appearance
|
||||
const submenu = this.getVisibleSubmenu()
|
||||
try {
|
||||
await submenu.waitFor({ state: 'visible', timeout: 1000 })
|
||||
} catch {
|
||||
// Click outside to reset, then reopen menu
|
||||
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
|
||||
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
|
||||
await this.menuTrigger.click()
|
||||
await this.menuLocator.waitFor({ state: 'visible' })
|
||||
// Re-hover on top-level menu to trigger submenu
|
||||
await topLevelMenu.hover()
|
||||
await submenu.waitFor({ state: 'visible', timeout: 1000 })
|
||||
}
|
||||
|
||||
let currentMenu = topLevelMenu
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const commandName = path[i]
|
||||
const menuItem = currentMenu
|
||||
.locator(
|
||||
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
|
||||
)
|
||||
const menuItem = submenu
|
||||
.locator(`.p-tieredmenu-item:has-text("${commandName}")`)
|
||||
.first()
|
||||
await menuItem.waitFor({ state: 'visible' })
|
||||
|
||||
// For the last item, click it
|
||||
if (i === path.length - 1) {
|
||||
await menuItem.click()
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, hover to open nested submenu
|
||||
await menuItem.hover()
|
||||
currentMenu = menuItem
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 42 KiB |
@@ -340,6 +340,11 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
|
||||
// Wait for workflow to appear in Browse section after sync
|
||||
const workflowItem =
|
||||
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
|
||||
await expect(workflowItem).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const nodeCount = await comfyPage.getGraphNodesCount()
|
||||
|
||||
// Get the bounding box of the canvas element
|
||||
@@ -358,6 +363,10 @@ test.describe('Workflows sidebar', () => {
|
||||
'#graph-canvas',
|
||||
{ targetPosition }
|
||||
)
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(nodeCount * 2)
|
||||
|
||||
// Wait for nodes to be inserted after drag-drop with retryable assertion
|
||||
await expect
|
||||
.poll(() => comfyPage.getGraphNodesCount(), { timeout: 3000 })
|
||||
.toBe(nodeCount * 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
parser as tseslintParser
|
||||
} from 'typescript-eslint'
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
import path from 'node:path'
|
||||
|
||||
const extraFileExtensions = ['.vue']
|
||||
|
||||
@@ -292,6 +293,9 @@ export default defineConfig([
|
||||
'no-console': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// Turn off ESLint rules that are already handled by oxlint
|
||||
...oxlint.buildFromOxlintConfigFile('./.oxlintrc.json')
|
||||
...oxlint.buildFromOxlintConfigFile(
|
||||
path.resolve(import.meta.dirname, '.oxlintrc.json')
|
||||
)
|
||||
])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.35.0",
|
||||
"version": "1.35.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -34,6 +34,7 @@
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint": "oxlint src --type-aware && eslint src --cache",
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
"preinstall": "pnpm dlx only-allow pnpm",
|
||||
@@ -46,6 +47,7 @@
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"zipdist": "node scripts/zipdist.js",
|
||||
"clean": "nx reset"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
<Skeleton width="8rem" height="2rem" />
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<Tag severity="secondary" rounded class="p-1 text-amber-400">
|
||||
<Tag
|
||||
v-if="!showCreditsOnly"
|
||||
severity="secondary"
|
||||
rounded
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
:class="
|
||||
@@ -18,7 +23,9 @@
|
||||
/>
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">{{ formattedBalance }}</div>
|
||||
<div :class="textClass">
|
||||
{{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,8 +39,9 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const { textClass } = defineProps<{
|
||||
const { textClass, showCreditsOnly } = defineProps<{
|
||||
textClass?: string
|
||||
showCreditsOnly?: boolean
|
||||
}>()
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
@@ -50,4 +58,14 @@ const formattedBalance = computed(() => {
|
||||
})
|
||||
return `${amount} ${t('credits.credits')}`
|
||||
})
|
||||
|
||||
const formattedCreditsOnly = computed(() => {
|
||||
// Backend returns cents despite the *_micros naming convention.
|
||||
const cents = authStore.balance?.amount_micros ?? 0
|
||||
const amount = formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value
|
||||
})
|
||||
return amount
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
<template>
|
||||
<!-- New Credits Design (default) -->
|
||||
<div
|
||||
v-if="useNewDesign"
|
||||
class="flex w-96 flex-col gap-8 p-8 bg-node-component-surface rounded-2xl border border-border-primary"
|
||||
>
|
||||
<div v-if="useNewDesign" class="flex w-112 flex-col gap-8 p-8">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="text-2xl font-semibold text-foreground-primary m-0">
|
||||
{{ $t('credits.topUp.addMoreCredits') }}
|
||||
<h1 class="text-2xl font-semibold text-white m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h1>
|
||||
<p class="text-sm text-foreground-secondary m-0">
|
||||
{{ $t('credits.topUp.creditsDescription') }}
|
||||
</p>
|
||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0 w-96">
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0">
|
||||
{{ $t('credits.topUp.creditsDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Balance Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<span class="text-sm text-foreground-secondary">{{
|
||||
<UserCredit text-class="text-3xl font-bold" show-credits-only />
|
||||
<span class="text-sm text-muted-foreground">{{
|
||||
$t('credits.creditsAvailable')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="refreshDate" class="text-sm text-foreground-secondary">
|
||||
{{ $t('credits.refreshes', { date: refreshDate }) }}
|
||||
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Options Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<span class="text-sm text-foreground-secondary">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.howManyCredits') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -42,7 +50,7 @@
|
||||
@select="selectedCredits = option.credits"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-foreground-secondary">
|
||||
<div class="text-xs text-muted-foreground w-96">
|
||||
{{ $t('credits.topUp.templateNote') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +61,8 @@
|
||||
:loading="loading"
|
||||
severity="primary"
|
||||
:label="$t('credits.topUp.buy')"
|
||||
class="w-full"
|
||||
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
|
||||
:pt="{ label: { class: 'text-white' } }"
|
||||
@click="handleBuy"
|
||||
/>
|
||||
</div>
|
||||
@@ -121,6 +130,7 @@ import {
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
|
||||
@@ -132,18 +142,17 @@ interface CreditOption {
|
||||
}
|
||||
|
||||
const {
|
||||
refreshDate,
|
||||
isInsufficientCredits = false,
|
||||
amountOptions = [5, 10, 20, 50],
|
||||
preselectedAmountOption = 10
|
||||
} = defineProps<{
|
||||
refreshDate?: string
|
||||
isInsufficientCredits?: boolean
|
||||
amountOptions?: number[]
|
||||
preselectedAmountOption?: number
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
// Use feature flag to determine design - defaults to true (new design)
|
||||
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
|
||||
|
||||
@@ -157,20 +166,20 @@ const loading = ref(false)
|
||||
|
||||
const creditOptions: CreditOption[] = [
|
||||
{
|
||||
credits: 1000,
|
||||
description: t('credits.topUp.videosEstimate', { count: 100 })
|
||||
credits: 1055, // $5.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 41 })
|
||||
},
|
||||
{
|
||||
credits: 5000,
|
||||
description: t('credits.topUp.videosEstimate', { count: 500 })
|
||||
credits: 2110, // $10.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 82 })
|
||||
},
|
||||
{
|
||||
credits: 10000,
|
||||
description: t('credits.topUp.videosEstimate', { count: 1000 })
|
||||
credits: 4220, // $20.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 184 })
|
||||
},
|
||||
{
|
||||
credits: 20000,
|
||||
description: t('credits.topUp.videosEstimate', { count: 2000 })
|
||||
credits: 10550, // $50.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 412 })
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -3,19 +3,17 @@
|
||||
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
|
||||
:class="[
|
||||
selected
|
||||
? 'bg-surface-secondary border-2 border-primary'
|
||||
: 'bg-surface-tertiary border border-border-primary hover:bg-surface-secondary'
|
||||
? 'bg-secondary-background border-2 border-border-default'
|
||||
: 'bg-component-node-disabled hover:bg-secondary-background border-2 border-transparent'
|
||||
]"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-medium text-foreground-primary">
|
||||
{{ formattedCredits }}
|
||||
</span>
|
||||
<span class="text-sm text-foreground-secondary">
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-base font-bold text-white">
|
||||
{{ formattedCredits }}
|
||||
</span>
|
||||
<span class="text-sm font-normal text-white">
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,6 +36,10 @@ defineEmits<{
|
||||
const { locale } = useI18n()
|
||||
|
||||
const formattedCredits = computed(() => {
|
||||
return formatCredits({ value: credits, locale: locale.value })
|
||||
return formatCredits({
|
||||
value: credits,
|
||||
locale: locale.value,
|
||||
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<TabPanel value="Credits" class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
@@ -74,7 +74,9 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: 'Bearer mock-token' })
|
||||
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
|
||||
balance: { amount_micros: 100_000 }, // 100,000 cents = ~211,000 credits
|
||||
isFetchingBalance: false
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -107,6 +109,39 @@ vi.mock('@/components/common/UserCredit.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock formatCreditsFromCents
|
||||
vi.mock('@/base/credits/comfyCredits', () => ({
|
||||
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
|
||||
}))
|
||||
|
||||
// Mock useExternalLink
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`)
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock useFeatureFlags
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => ({
|
||||
flags: {
|
||||
subscriptionTiersEnabled: true
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock useTelemetry
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock isCloud
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
default: {
|
||||
name: 'SubscribeButtonMock',
|
||||
@@ -145,27 +180,37 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.text()).toContain('test@example.com')
|
||||
})
|
||||
|
||||
it('renders logout button with correct props', () => {
|
||||
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (last button)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[4]
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
cents: 100_000,
|
||||
locale: 'en',
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
|
||||
// Check that logout button has correct props
|
||||
expect(logoutButton.props('label')).toBe('Log Out')
|
||||
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
|
||||
// Verify the formatted credit string (1000) is rendered in the DOM
|
||||
expect(wrapper.text()).toContain('1000')
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings button is clicked', async () => {
|
||||
it('renders logout menu item with correct text', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the settings button (third button)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const settingsButton = buttons[2]
|
||||
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
|
||||
expect(logoutItem.exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Log Out')
|
||||
})
|
||||
|
||||
// Click the settings button
|
||||
await settingsButton.trigger('click')
|
||||
it('opens user settings and emits close event when settings item is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const settingsItem = wrapper.find('[data-testid="user-settings-menu-item"]')
|
||||
expect(settingsItem.exists()).toBe(true)
|
||||
|
||||
await settingsItem.trigger('click')
|
||||
|
||||
// Verify showSettingsDialog was called with 'user'
|
||||
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
|
||||
@@ -175,15 +220,13 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('calls logout function and emits close event when logout button is clicked', async () => {
|
||||
it('calls logout function and emits close event when logout item is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (last button)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[4]
|
||||
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
|
||||
expect(logoutItem.exists()).toBe(true)
|
||||
|
||||
// Click the logout button
|
||||
await logoutButton.trigger('click')
|
||||
await logoutItem.trigger('click')
|
||||
|
||||
// Verify handleSignOut was called
|
||||
expect(mockHandleSignOut).toHaveBeenCalled()
|
||||
@@ -193,15 +236,15 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
|
||||
it('opens API pricing docs and emits close event when partner nodes item is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the Partner Nodes info button (first one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const partnerNodesButton = buttons[0]
|
||||
const partnerNodesItem = wrapper.find(
|
||||
'[data-testid="partner-nodes-menu-item"]'
|
||||
)
|
||||
expect(partnerNodesItem.exists()).toBe(true)
|
||||
|
||||
// Click the Partner Nodes button
|
||||
await partnerNodesButton.trigger('click')
|
||||
await partnerNodesItem.trigger('click')
|
||||
|
||||
// Verify window.open was called with the correct URL
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
@@ -217,11 +260,9 @@ describe('CurrentUserPopover', () => {
|
||||
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the top-up button (second one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const topUpButton = buttons[1]
|
||||
const topUpButton = wrapper.find('[data-testid="add-credits-button"]')
|
||||
expect(topUpButton.exists()).toBe(true)
|
||||
|
||||
// Click the top-up button
|
||||
await topUpButton.trigger('click')
|
||||
|
||||
// Verify showTopUpCreditsDialog was called
|
||||
|
||||
@@ -1,120 +1,131 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div class="current-user-popover w-72">
|
||||
<div
|
||||
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
<div class="p-3">
|
||||
<div class="flex flex-col items-center">
|
||||
<UserAvatar
|
||||
class="mb-3"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{
|
||||
'text-2xl!': !userPhotoUrl
|
||||
}"
|
||||
size="large"
|
||||
/>
|
||||
<div class="flex flex-col items-center px-0 py-3 mb-4">
|
||||
<UserAvatar
|
||||
class="mb-1"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{
|
||||
'text-2xl!': !userPhotoUrl
|
||||
}"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- User Details -->
|
||||
<h3 class="my-0 mb-1 truncate text-lg font-semibold">
|
||||
{{ userDisplayName || $t('g.user') }}
|
||||
</h3>
|
||||
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- User Details -->
|
||||
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
|
||||
{{ userDisplayName || $t('g.user') }}
|
||||
</h3>
|
||||
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isActiveSubscription" class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<UserCredit text-class="text-2xl" />
|
||||
<div
|
||||
v-if="flags.subscriptionTiersEnabled"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<i
|
||||
v-tooltip="{
|
||||
value: $t('credits.unified.tooltip'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
class="icon-[lucide--circle-help] text-muted cursor-help text-xs"
|
||||
/>
|
||||
<span class="text-xs text-muted">{{
|
||||
$t('credits.unified.message')
|
||||
}}</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('subscription.partnerNodesCredits')"
|
||||
severity="secondary"
|
||||
text
|
||||
size="small"
|
||||
class="pl-6 p-0 h-auto justify-start"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'hover:bg-transparent active:bg-transparent'
|
||||
}
|
||||
}"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
/>
|
||||
</div>
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-sm" />
|
||||
<span class="text-base font-normal text-base-foreground flex-1">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<Button
|
||||
:label="$t('credits.topUp.topUp')"
|
||||
:label="$t('subscription.addCredits')"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubscribeButton
|
||||
v-else
|
||||
class="mx-4"
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="small"
|
||||
variant="gradient"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
|
||||
<Divider class="my-2" />
|
||||
<!-- Credits info row -->
|
||||
<div
|
||||
v-if="flags.subscriptionTiersEnabled && isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-0"
|
||||
>
|
||||
<i
|
||||
v-tooltip="{
|
||||
value: $t('credits.unified.tooltip'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
class="icon-[lucide--circle-help] cursor-help text-xs text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">{{
|
||||
$t('credits.unified.message')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('userSettings.title')"
|
||||
icon="pi pi-cog"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
@click="handleOpenUserSettings"
|
||||
/>
|
||||
<Divider class="my-2 mx-0" />
|
||||
|
||||
<Button
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="justify-start"
|
||||
:label="$t(planSettingsLabel)"
|
||||
icon="pi pi-receipt"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
>
|
||||
<i class="icon-[lucide--tag] text-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
$t('subscription.partnerNodesCredits')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
data-testid="plan-credits-menu-item"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
/>
|
||||
>
|
||||
<i class="icon-[lucide--receipt-text] text-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
$t(planSettingsLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="my-2" />
|
||||
<div
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
data-testid="user-settings-menu-item"
|
||||
@click="handleOpenUserSettings"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2] text-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
$t('userSettings.title')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
<Divider class="my-2 mx-0" />
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
data-testid="logout-menu-item"
|
||||
@click="handleLogout"
|
||||
/>
|
||||
>
|
||||
<i class="icon-[lucide--log-out] text-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
$t('auth.signOut.signOut')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
@@ -124,6 +135,7 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@@ -138,9 +150,24 @@ const planSettingsLabel = isCloud
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
// Backend returns cents despite the *_micros naming convention.
|
||||
const cents = authStore.balance?.amount_micros ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface VueNodeData {
|
||||
}
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
shape?: number
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
@@ -234,7 +235,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
shape: node.shape
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +573,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'shape':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
shape:
|
||||
typeof propertyEvent.newValue === 'number'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -495,6 +495,7 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
set shape(v: RenderShape | 'default' | 'box' | 'round' | 'circle' | 'card') {
|
||||
const oldValue = this._shape
|
||||
switch (v) {
|
||||
case 'default':
|
||||
this._shape = undefined
|
||||
@@ -514,6 +515,14 @@ export class LGraphNode
|
||||
default:
|
||||
this._shape = v
|
||||
}
|
||||
if (oldValue !== this._shape) {
|
||||
this.graph?.trigger('node:property:changed', {
|
||||
nodeId: this.id,
|
||||
property: 'shape',
|
||||
oldValue,
|
||||
newValue: this._shape
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -851,13 +860,12 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
if (info.widgets_values) {
|
||||
const widgetsWithValue = this.widgets
|
||||
.values()
|
||||
.filter((w) => w.serialize !== false)
|
||||
.filter((_w, idx) => idx < info.widgets_values!.length)
|
||||
widgetsWithValue.forEach(
|
||||
(widget, i) => (widget.value = info.widgets_values![i])
|
||||
)
|
||||
let i = 0
|
||||
for (const widget of this.widgets ?? []) {
|
||||
if (widget.serialize === false) continue
|
||||
if (i >= info.widgets_values.length) break
|
||||
widget.value = info.widgets_values[i++]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "دليل مستخدم سطح المكتب",
|
||||
"docs": "الوثائق",
|
||||
"github": "GitHub",
|
||||
"helpFeedback": "المساعدة والتعليقات",
|
||||
"loadingReleases": "جارٍ تحميل الإصدارات...",
|
||||
"managerExtension": "المدير الموسع",
|
||||
"more": "المزيد...",
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
{
|
||||
"Comfy-Desktop_CheckForUpdates": {
|
||||
"label": "Check for Updates"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
|
||||
"label": "Open Custom Nodes Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenInputsFolder": {
|
||||
"label": "Open Inputs Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenLogsFolder": {
|
||||
"label": "Open Logs Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelConfig": {
|
||||
"label": "Open extra_model_paths.yaml"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelsFolder": {
|
||||
"label": "Open Models Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenOutputsFolder": {
|
||||
"label": "Open Outputs Folder"
|
||||
},
|
||||
"Comfy-Desktop_OpenDevTools": {
|
||||
"label": "Open DevTools"
|
||||
},
|
||||
"Comfy-Desktop_OpenUserGuide": {
|
||||
"label": "Desktop User Guide"
|
||||
},
|
||||
"Comfy-Desktop_Quit": {
|
||||
"label": "Quit"
|
||||
},
|
||||
"Comfy-Desktop_Reinstall": {
|
||||
"label": "Reinstall"
|
||||
},
|
||||
"Comfy-Desktop_Restart": {
|
||||
"label": "Restart"
|
||||
},
|
||||
"Comfy_3DViewer_Open3DViewer": {
|
||||
"label": "Open 3D Viewer (Beta) for Selected Node"
|
||||
},
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"relativeTime": {
|
||||
"now": "now",
|
||||
"yearsAgo": "{count}y ago",
|
||||
"monthsAgo": "{count}mo ago",
|
||||
"monthsAgo": "{count}mo ago",
|
||||
"weeksAgo": "{count}w ago",
|
||||
"daysAgo": "{count}d ago",
|
||||
"hoursAgo": "{count}h ago",
|
||||
@@ -475,6 +475,7 @@
|
||||
"notSupported": {
|
||||
"title": "Your device is not supported",
|
||||
"message": "Only following devices are supported:",
|
||||
"illustrationAlt": "Sad girl illustration",
|
||||
"learnMore": "Learn More",
|
||||
"reportIssue": "Report Issue",
|
||||
"supportedDevices": {
|
||||
@@ -1053,6 +1054,18 @@
|
||||
"Edit": "Edit",
|
||||
"View": "View",
|
||||
"Help": "Help",
|
||||
"Check for Updates": "Check for Updates",
|
||||
"Open Custom Nodes Folder": "Open Custom Nodes Folder",
|
||||
"Open Inputs Folder": "Open Inputs Folder",
|
||||
"Open Logs Folder": "Open Logs Folder",
|
||||
"Open extra_model_paths_yaml": "Open extra_model_paths.yaml",
|
||||
"Open Models Folder": "Open Models Folder",
|
||||
"Open Outputs Folder": "Open Outputs Folder",
|
||||
"Open DevTools": "Open DevTools",
|
||||
"Desktop User Guide": "Desktop User Guide",
|
||||
"Quit": "Quit",
|
||||
"Reinstall": "Reinstall",
|
||||
"Restart": "Restart",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
|
||||
"Experimental: Browse Model Assets": "Experimental: Browse Model Assets",
|
||||
"Browse Templates": "Browse Templates",
|
||||
@@ -1847,6 +1860,8 @@
|
||||
"seeDetails": "See details",
|
||||
"topUp": "Top Up",
|
||||
"addMoreCredits": "Add more credits",
|
||||
"addMoreCreditsToRun": "Add more credits to run",
|
||||
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
|
||||
"creditsDescription": "Credits are used to run workflows or partner nodes.",
|
||||
"howManyCredits": "How many credits would you like to add?",
|
||||
"videosEstimate": "~{count} videos*",
|
||||
@@ -1869,7 +1884,7 @@
|
||||
"accountInitialized": "Account initialized",
|
||||
"unified": {
|
||||
"message": "Credits have been unified",
|
||||
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits.\nLearn more here."
|
||||
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
|
||||
}
|
||||
},
|
||||
"subscription": {
|
||||
@@ -1878,7 +1893,7 @@
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"beta": "BETA",
|
||||
"perMonth": "USD / month",
|
||||
"perMonth": "/ month",
|
||||
"renewsDate": "Renews {date}",
|
||||
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
@@ -1893,6 +1908,10 @@
|
||||
"monthlyBonusDescription": "Monthly credit bonus",
|
||||
"prepaidDescription": "Pre-paid credits",
|
||||
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
|
||||
"creditsRemainingThisMonth": "Credits remaining for this month",
|
||||
"creditsYouveAdded": "Credits you've added",
|
||||
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
||||
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
||||
"nextBillingCycle": "next billing cycle",
|
||||
"yourPlanIncludes": "Your plan includes:",
|
||||
"viewMoreDetails": "View more details",
|
||||
@@ -1903,6 +1922,55 @@
|
||||
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
|
||||
"benefit2": "Up to 30 min runtime per job"
|
||||
},
|
||||
"tiers": {
|
||||
"founder": {
|
||||
"name": "Founder's Edition Standard",
|
||||
"price": "20.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "5,460 monthly credits",
|
||||
"maxDuration": "30 min max duration of each workflow run",
|
||||
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCredits": "Add more credits whenever"
|
||||
}
|
||||
},
|
||||
"standard": {
|
||||
"name": "Standard",
|
||||
"price": "20.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "4,200 monthly credits",
|
||||
"maxDuration": "30 min max duration of each workflow run",
|
||||
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCredits": "Add more credits whenever",
|
||||
"customLoRAs": "Import your own LoRAs",
|
||||
"videoEstimate": "164"
|
||||
}
|
||||
},
|
||||
"creator": {
|
||||
"name": "Creator",
|
||||
"price": "35.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "7,400",
|
||||
"monthlyCreditsLabel": "monthly credits",
|
||||
"maxDuration": "30 min",
|
||||
"maxDurationLabel": "max duration of each workflow run",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"price": "100.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "21,100 monthly credits",
|
||||
"maxDuration": "1 hr max duration of each workflow run",
|
||||
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCredits": "Add more credits whenever",
|
||||
"customLoRAs": "Import your own LoRAs",
|
||||
"videoEstimate": "821"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": {
|
||||
"title": "Subscribe to",
|
||||
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
|
||||
@@ -1922,10 +1990,10 @@
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "view enterprise",
|
||||
"partnerNodesCredits": "Partner Nodes pricing table"
|
||||
"partnerNodesCredits": "Partner nodes pricing"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "User Settings",
|
||||
"title": "My Account Settings",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"provider": "Sign-in Provider",
|
||||
@@ -2324,4 +2392,4 @@
|
||||
"recentReleases": "Recent releases",
|
||||
"helpCenterMenu": "Help Center Menu"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,30 @@
|
||||
{
|
||||
"Comfy-Desktop_AutoUpdate": {
|
||||
"name": "Automatically check for updates"
|
||||
},
|
||||
"Comfy-Desktop_SendStatistics": {
|
||||
"name": "Send anonymous usage metrics"
|
||||
},
|
||||
"Comfy-Desktop_UV_PypiInstallMirror": {
|
||||
"name": "Pypi Install Mirror",
|
||||
"tooltip": "Default pip install mirror"
|
||||
},
|
||||
"Comfy-Desktop_UV_PythonInstallMirror": {
|
||||
"name": "Python Install Mirror",
|
||||
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme."
|
||||
},
|
||||
"Comfy-Desktop_UV_TorchInstallMirror": {
|
||||
"name": "Torch Install Mirror",
|
||||
"tooltip": "Pip install mirror for pytorch"
|
||||
},
|
||||
"Comfy-Desktop_WindowStyle": {
|
||||
"name": "Window Style",
|
||||
"tooltip": "Custom: Replace the system title bar with ComfyUI's Top menu",
|
||||
"options": {
|
||||
"default": "default",
|
||||
"custom": "custom"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_BackgroundImage": {
|
||||
"name": "Canvas background image",
|
||||
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "Guía de usuario de escritorio",
|
||||
"docs": "Documentación",
|
||||
"github": "Github",
|
||||
"helpFeedback": "Ayuda y comentarios",
|
||||
"loadingReleases": "Cargando versiones...",
|
||||
"managerExtension": "Extensión del Administrador",
|
||||
"more": "Más...",
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "Guide utilisateur de bureau",
|
||||
"docs": "Docs",
|
||||
"github": "Github",
|
||||
"helpFeedback": "Aide & Retour",
|
||||
"loadingReleases": "Chargement des versions...",
|
||||
"managerExtension": "Manager Extension",
|
||||
"more": "Plus...",
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "デスクトップユーザーガイド",
|
||||
"docs": "ドキュメント",
|
||||
"github": "Github",
|
||||
"helpFeedback": "ヘルプとフィードバック",
|
||||
"loadingReleases": "リリースを読み込み中...",
|
||||
"managerExtension": "Manager Extension",
|
||||
"more": "もっと見る...",
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "데스크톱 사용자 가이드",
|
||||
"docs": "문서",
|
||||
"github": "Github",
|
||||
"helpFeedback": "도움말 및 피드백",
|
||||
"loadingReleases": "릴리즈 불러오는 중...",
|
||||
"managerExtension": "관리자 확장",
|
||||
"more": "더보기...",
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "Руководство пользователя для Desktop",
|
||||
"docs": "Документация",
|
||||
"github": "Github",
|
||||
"helpFeedback": "Помощь и обратная связь",
|
||||
"loadingReleases": "Загрузка релизов...",
|
||||
"managerExtension": "Расширение менеджера",
|
||||
"more": "Ещё...",
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "Masaüstü Kullanıcı Kılavuzu",
|
||||
"docs": "Belgeler",
|
||||
"github": "Github",
|
||||
"helpFeedback": "Yardım ve Geri Bildirim",
|
||||
"loadingReleases": "Sürümler yükleniyor...",
|
||||
"managerExtension": "Yönetici Uzantısı",
|
||||
"more": "Daha Fazla...",
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "桌面版使用指南",
|
||||
"docs": "文件",
|
||||
"github": "Github",
|
||||
"helpFeedback": "幫助與回饋",
|
||||
"loadingReleases": "正在載入版本資訊…",
|
||||
"managerExtension": "管理器擴充功能",
|
||||
"more": "更多…",
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "桌面端用户指南",
|
||||
"docs": "文档",
|
||||
"github": "Github",
|
||||
"helpFeedback": "帮助与反馈",
|
||||
"loadingReleases": "加载发布信息...",
|
||||
"managerExtension": "管理扩展",
|
||||
"more": "更多...",
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
|
||||
<!-- Model Info Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<p class="mt-0 text-base-foreground rounded-lg">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-3 bg-secondary-background p-3 rounded-lg"
|
||||
>
|
||||
<img
|
||||
v-if="previewImage"
|
||||
:src="previewImage"
|
||||
:alt="metadata?.filename || metadata?.name || 'Model preview'"
|
||||
class="w-14 h-14 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
<p class="m-0 text-base-foreground">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
@@ -40,7 +49,8 @@ import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
defineProps<{
|
||||
metadata: AssetMetadata | null
|
||||
metadata?: AssetMetadata
|
||||
previewImage?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
v-else-if="currentStep === 2"
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
:preview-image="wizardData.previewImage"
|
||||
/>
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
@@ -23,6 +24,7 @@
|
||||
:error="uploadError"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
:preview-image="wizardData.previewImage"
|
||||
/>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
|
||||
@@ -25,8 +25,14 @@
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-row items-start p-4 bg-modal-card-background rounded-lg"
|
||||
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
|
||||
>
|
||||
<img
|
||||
v-if="previewImage"
|
||||
:src="previewImage"
|
||||
:alt="metadata?.filename || metadata?.name || 'Model preview'"
|
||||
class="w-14 h-14 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-base-foreground m-0">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
@@ -63,7 +69,8 @@ import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
defineProps<{
|
||||
status: 'idle' | 'uploading' | 'success' | 'error'
|
||||
error?: string
|
||||
metadata: AssetMetadata | null
|
||||
modelType: string | undefined
|
||||
metadata?: AssetMetadata
|
||||
modelType?: string
|
||||
previewImage?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -9,9 +9,10 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
interface WizardData {
|
||||
url: string
|
||||
metadata: AssetMetadata | null
|
||||
metadata?: AssetMetadata
|
||||
name: string
|
||||
tags: string[]
|
||||
previewImage?: string
|
||||
}
|
||||
|
||||
interface ModelTypeOption {
|
||||
@@ -30,7 +31,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
|
||||
const wizardData = ref<WizardData>({
|
||||
url: '',
|
||||
metadata: null,
|
||||
name: '',
|
||||
tags: []
|
||||
})
|
||||
@@ -91,6 +91,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
// Pre-fill name from metadata
|
||||
wizardData.value.name = metadata.filename || metadata.name || ''
|
||||
|
||||
// Store preview image if available
|
||||
wizardData.value.previewImage = metadata.preview_image
|
||||
|
||||
// Pre-fill model type from metadata tags if available
|
||||
if (metadata.tags && metadata.tags.length > 0) {
|
||||
wizardData.value.tags = metadata.tags
|
||||
@@ -134,6 +137,34 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
wizardData.value.metadata?.name ||
|
||||
'model'
|
||||
|
||||
let previewId: string | undefined
|
||||
|
||||
// Upload preview image first if available
|
||||
if (wizardData.value.previewImage) {
|
||||
try {
|
||||
const baseFilename = filename.split('.')[0]
|
||||
|
||||
// Extract extension from data URL MIME type
|
||||
let extension = 'png'
|
||||
const mimeMatch = wizardData.value.previewImage.match(
|
||||
/^data:image\/([^;]+);/
|
||||
)
|
||||
if (mimeMatch) {
|
||||
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
|
||||
}
|
||||
|
||||
const previewAsset = await assetService.uploadAssetFromBase64({
|
||||
data: wizardData.value.previewImage,
|
||||
name: `${baseFilename}_preview.${extension}`,
|
||||
tags: ['preview']
|
||||
})
|
||||
previewId = previewAsset.id
|
||||
} catch (error) {
|
||||
console.error('Failed to upload preview image:', error)
|
||||
// Continue with model upload even if preview fails
|
||||
}
|
||||
}
|
||||
|
||||
await assetService.uploadAssetFromUrl({
|
||||
url: wizardData.value.url,
|
||||
name: filename,
|
||||
@@ -142,7 +173,8 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
source: 'civitai',
|
||||
source_url: wizardData.value.url,
|
||||
model_type: selectedModelType.value
|
||||
}
|
||||
},
|
||||
preview_id: previewId
|
||||
})
|
||||
|
||||
uploadStatus.value = 'success'
|
||||
|
||||
@@ -54,6 +54,7 @@ const zAssetMetadata = z.object({
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
preview_url: z.string().optional(),
|
||||
preview_image: z.string().optional(),
|
||||
validation: zValidationResult.optional()
|
||||
})
|
||||
|
||||
|
||||
@@ -392,6 +392,59 @@ function createAssetService() {
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an asset from base64 data
|
||||
*
|
||||
* @param params - Upload parameters
|
||||
* @param params.data - Base64 data URL (e.g., "data:image/png;base64,...")
|
||||
* @param params.name - Display name (determines extension)
|
||||
* @param params.tags - Optional freeform tags
|
||||
* @param params.user_metadata - Optional custom metadata object
|
||||
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
|
||||
* @throws Error if upload fails
|
||||
*/
|
||||
async function uploadAssetFromBase64(params: {
|
||||
data: string
|
||||
name: string
|
||||
tags?: string[]
|
||||
user_metadata?: Record<string, any>
|
||||
}): Promise<AssetItem & { created_new: boolean }> {
|
||||
// Validate that data is a data URL
|
||||
if (!params.data || !params.data.startsWith('data:')) {
|
||||
throw new Error(
|
||||
'Invalid data URL: expected a string starting with "data:"'
|
||||
)
|
||||
}
|
||||
|
||||
// Convert base64 data URL to Blob
|
||||
const blob = await fetch(params.data).then((r) => r.blob())
|
||||
|
||||
// Create FormData and append the blob
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, params.name)
|
||||
|
||||
if (params.tags) {
|
||||
formData.append('tags', JSON.stringify(params.tags))
|
||||
}
|
||||
|
||||
if (params.user_metadata) {
|
||||
formData.append('user_metadata', JSON.stringify(params.user_metadata))
|
||||
}
|
||||
|
||||
const res = await api.fetchApi(ASSETS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to upload asset from base64: ${res.status} ${res.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
@@ -402,7 +455,8 @@ function createAssetService() {
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl
|
||||
uploadAssetFromUrl,
|
||||
uploadAssetFromBase64
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-xs text-text-primary" />
|
||||
<div class="flex flex-col items-start gap-0 self-stretch">
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<i class="pi pi-check text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ $t('subscription.benefits.benefit1') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-xs text-text-primary" />
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<i class="pi pi-check text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ $t('subscription.benefits.benefit2') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="$t('subscription.viewMoreDetails')"
|
||||
text
|
||||
icon="pi pi-external-link"
|
||||
icon-pos="left"
|
||||
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-xs text-text-secondary'
|
||||
},
|
||||
label: {
|
||||
class: 'text-sm text-text-secondary'
|
||||
}
|
||||
}"
|
||||
@click="handleViewMoreDetails"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const handleViewMoreDetails = () => {
|
||||
window.open('https://www.comfy.org/cloud/pricing', '_blank')
|
||||
}
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
@@ -19,9 +19,12 @@
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ tierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{
|
||||
$t('subscription.perMonth')
|
||||
}}</span>
|
||||
@@ -59,7 +62,7 @@
|
||||
class: 'text-text-primary'
|
||||
}
|
||||
}"
|
||||
@click="manageSubscription"
|
||||
@click="showSubscriptionDialog"
|
||||
/>
|
||||
<SubscribeButton
|
||||
v-else
|
||||
@@ -75,17 +78,6 @@
|
||||
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm">
|
||||
{{ $t('subscription.partnerNodesBalance') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.partnerNodesDescription') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
@@ -112,7 +104,7 @@
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-secondary">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton
|
||||
@@ -133,12 +125,18 @@
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<div v-else class="text-sm text-text-secondary font-bold">
|
||||
<div
|
||||
v-else
|
||||
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
|
||||
>
|
||||
{{ monthlyBonusCredits }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.monthlyBonusDescription') }}
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div
|
||||
class="text-sm truncate text-muted"
|
||||
:title="$t('subscription.creditsRemainingThisMonth')"
|
||||
>
|
||||
{{ $t('subscription.creditsRemainingThisMonth') }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="refreshTooltip"
|
||||
@@ -146,7 +144,7 @@
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="h-4 w-4"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
@@ -161,12 +159,18 @@
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<div v-else class="text-sm text-text-secondary font-bold">
|
||||
<div
|
||||
v-else
|
||||
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
|
||||
>
|
||||
{{ prepaidCredits }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.prepaidDescription') }}
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div
|
||||
class="text-sm truncate text-muted"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="$t('subscription.prepaidCreditsInfo')"
|
||||
@@ -174,7 +178,7 @@
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="h-4 w-4"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
@@ -190,8 +194,7 @@
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-text-secondary underline hover:text-text-secondary"
|
||||
style="text-decoration: underline"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
@@ -216,14 +219,47 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="text-sm">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<SubscriptionBenefits />
|
||||
<div class="flex flex-col gap-0">
|
||||
<div
|
||||
v-for="benefit in tierBenefits"
|
||||
:key="benefit.key"
|
||||
class="flex items-center gap-2 py-2"
|
||||
>
|
||||
<i
|
||||
v-if="benefit.type === 'feature'"
|
||||
class="pi pi-check text-xs text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-else-if="benefit.type === 'metric' && benefit.value"
|
||||
class="text-sm font-normal whitespace-nowrap text-text-primary"
|
||||
>
|
||||
{{ benefit.value }}
|
||||
</span>
|
||||
<span class="text-sm text-muted">
|
||||
{{ benefit.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View More Details - Outside main content -->
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<i class="pi pi-external-link text-muted"></i>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline hover:opacity-80 text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewMoreDetailsPlans') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -307,28 +343,79 @@
|
||||
import Button from 'primevue/button'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
formattedMonthlyPrice,
|
||||
manageSubscription,
|
||||
handleInvoiceHistory
|
||||
} = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
// Tier data - hardcoded for Creator tier as requested
|
||||
const tierName = computed(() => t('subscription.tiers.creator.name'))
|
||||
const tierPrice = computed(() => t('subscription.tiers.creator.price'))
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed(() => {
|
||||
const baseBenefits: Benefit[] = [
|
||||
{
|
||||
key: 'monthlyCredits',
|
||||
type: 'metric',
|
||||
value: t('subscription.tiers.creator.benefits.monthlyCredits'),
|
||||
label: t('subscription.tiers.creator.benefits.monthlyCreditsLabel')
|
||||
},
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t('subscription.tiers.creator.benefits.maxDuration'),
|
||||
label: t('subscription.tiers.creator.benefits.maxDurationLabel')
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.tiers.creator.benefits.gpuLabel')
|
||||
},
|
||||
{
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.tiers.creator.benefits.addCreditsLabel')
|
||||
},
|
||||
{
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.tiers.creator.benefits.customLoRAsLabel')
|
||||
}
|
||||
]
|
||||
|
||||
return baseBenefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
|
||||
@@ -9,16 +9,20 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
*/
|
||||
export function useSubscriptionCredits() {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const { t, locale } = useI18n()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const formatBalance = (maybeCents?: number) => {
|
||||
// Backend returns cents despite the *_micros naming convention.
|
||||
const cents = maybeCents ?? 0
|
||||
const amount = formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value
|
||||
locale: locale.value,
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
})
|
||||
return `${amount} ${t('credits.credits')}`
|
||||
return amount
|
||||
}
|
||||
|
||||
const totalCredits = computed(() =>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useSettingUI(
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
|
||||
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,24 +29,16 @@
|
||||
</p>
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<Skeleton
|
||||
v-if="isLoading && !imageError"
|
||||
border-radius="5px"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
<div v-if="showLoader && !imageError" class="size-full">
|
||||
<Skeleton border-radius="5px" width="100%" height="100%" />
|
||||
</div>
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-if="!imageError"
|
||||
ref="currentImageEl"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
:class="
|
||||
cn(
|
||||
'block size-full object-contain pointer-events-none',
|
||||
isLoading && 'invisible'
|
||||
)
|
||||
"
|
||||
class="block size-full object-contain pointer-events-none"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
@@ -91,7 +83,7 @@
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-base-foreground">
|
||||
<span v-else-if="showLoader" class="text-base-foreground">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
@@ -117,6 +109,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { useToast } from 'primevue'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -126,7 +119,6 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
@@ -149,10 +141,19 @@ const currentIndex = ref(0)
|
||||
const isHovered = ref(false)
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const showLoader = ref(false)
|
||||
|
||||
const currentImageEl = ref<HTMLImageElement>()
|
||||
|
||||
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
|
||||
() => {
|
||||
showLoader.value = true
|
||||
},
|
||||
250,
|
||||
// Make sure it doesnt run on component mount
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Computed values
|
||||
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
|
||||
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
|
||||
@@ -169,17 +170,19 @@ watch(
|
||||
|
||||
// Reset loading and error states when URLs change
|
||||
actualDimensions.value = null
|
||||
|
||||
imageError.value = false
|
||||
isLoading.value = newUrls.length > 0
|
||||
if (newUrls.length > 0) startDelayedLoader()
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handleImageLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
isLoading.value = false
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
@@ -187,7 +190,8 @@ const handleImageLoad = (event: Event) => {
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
isLoading.value = false
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
@@ -230,8 +234,7 @@ const setCurrentIndex = (index: number) => {
|
||||
if (currentIndex.value === index) return
|
||||
if (index >= 0 && index < props.imageUrls.length) {
|
||||
currentIndex.value = index
|
||||
actualDimensions.value = null
|
||||
isLoading.value = true
|
||||
startDelayedLoader()
|
||||
imageError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
'bg-component-node-background lg-node absolute pb-1',
|
||||
|
||||
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
|
||||
'rounded-2xl touch-none flex flex-col',
|
||||
shapeClass,
|
||||
'touch-none flex flex-col',
|
||||
'border-1 border-solid border-component-node-border',
|
||||
// hover (only when node should handle events)
|
||||
// hover (only when node should handle events)1
|
||||
shouldHandleNodePointerEvents &&
|
||||
'hover:ring-7 ring-node-component-ring',
|
||||
'outline-transparent outline-2',
|
||||
@@ -21,9 +22,9 @@
|
||||
outlineClass,
|
||||
cursorClass,
|
||||
{
|
||||
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0`]:
|
||||
bypassed,
|
||||
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
|
||||
muted,
|
||||
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
|
||||
},
|
||||
@@ -140,7 +141,8 @@ import { st } from '@/i18n'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LGraphEventMode,
|
||||
LiteGraph
|
||||
LiteGraph,
|
||||
RenderShape
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -383,6 +385,28 @@ const cursorClass = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const shapeClass = computed(() => {
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return 'rounded-none'
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
|
||||
default:
|
||||
return 'rounded-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
const beforeShapeClass = computed(() => {
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return 'before:rounded-none'
|
||||
case RenderShape.CARD:
|
||||
return 'before:rounded-tl-2xl before:rounded-br-2xl before:rounded-tr-none before:rounded-bl-none'
|
||||
default:
|
||||
return 'before:rounded-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
handleNodeCollapse(nodeData.id, !isCollapsed.value)
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-header py-2 pl-2 pr-3 text-sm rounded-t-2xl w-full min-w-0',
|
||||
'lg-node-header py-2 pl-2 pr-3 text-sm w-full min-w-0',
|
||||
'text-node-component-header bg-node-component-header-surface',
|
||||
collapsed && 'rounded-2xl'
|
||||
headerShapeClass
|
||||
)
|
||||
"
|
||||
:style="headerStyle"
|
||||
@@ -109,7 +109,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { st } from '@/i18n'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
@@ -216,6 +216,28 @@ const nodeBadges = computed<NodeBadgeProps[]>(() =>
|
||||
)
|
||||
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
|
||||
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
|
||||
|
||||
const headerShapeClass = computed(() => {
|
||||
if (collapsed) {
|
||||
switch (nodeData?.shape) {
|
||||
case RenderShape.BOX:
|
||||
return 'rounded-none'
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
|
||||
default:
|
||||
return 'rounded-2xl'
|
||||
}
|
||||
}
|
||||
switch (nodeData?.shape) {
|
||||
case RenderShape.BOX:
|
||||
return 'rounded-t-none'
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-tl-2xl rounded-tr-none'
|
||||
default:
|
||||
return 'rounded-t-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
// Subgraph detection
|
||||
const isSubgraphNode = computed(() => {
|
||||
if (!nodeData?.id) return false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
@@ -13,13 +14,14 @@ import type {
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
|
||||
|
||||
function useNodeDragIndividual() {
|
||||
const mutations = useLayoutMutations()
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const { selectedNodeIds, selectedItems } = storeToRefs(useCanvasStore())
|
||||
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = useTransformState()
|
||||
@@ -37,6 +39,10 @@ function useNodeDragIndividual() {
|
||||
let rafId: number | null = null
|
||||
let stopShiftSync: (() => void) | null = null
|
||||
|
||||
// For groups: track the last applied canvas delta to compute frame delta
|
||||
let lastCanvasDelta: Point | null = null
|
||||
let selectedGroups: LGraphGroup[] | null = null
|
||||
|
||||
function startDrag(event: PointerEvent, nodeId: NodeId) {
|
||||
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||
if (!layout) return
|
||||
@@ -67,6 +73,10 @@ function useNodeDragIndividual() {
|
||||
otherSelectedNodesStartPositions = null
|
||||
}
|
||||
|
||||
// Capture selected groups (filter from selectedItems which only contains selected items)
|
||||
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
|
||||
lastCanvasDelta = { x: 0, y: 0 }
|
||||
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
}
|
||||
|
||||
@@ -127,6 +137,21 @@ function useNodeDragIndividual() {
|
||||
mutations.moveNode(otherNodeId, newOtherPosition)
|
||||
}
|
||||
}
|
||||
|
||||
// Move selected groups using frame delta (difference from last frame)
|
||||
// This matches LiteGraph's behavior which uses delta-based movement
|
||||
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
|
||||
const frameDelta = {
|
||||
x: canvasDelta.x - lastCanvasDelta.x,
|
||||
y: canvasDelta.y - lastCanvasDelta.y
|
||||
}
|
||||
|
||||
for (const group of selectedGroups) {
|
||||
group.move(frameDelta.x, frameDelta.y, true)
|
||||
}
|
||||
}
|
||||
|
||||
lastCanvasDelta = canvasDelta
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,6 +220,8 @@ function useNodeDragIndividual() {
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
selectedGroups = null
|
||||
lastCanvasDelta = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync?.()
|
||||
|
||||
@@ -386,11 +386,12 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
key: 'top-up-credits',
|
||||
component: TopUpCreditsDialogContent,
|
||||
headerComponent: ComfyOrgHeader,
|
||||
props: options,
|
||||
dialogComponentProps: {
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-3!' }
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0!' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -32,16 +32,12 @@ describe('CreditTopUpOption', () => {
|
||||
expect(wrapper.text()).toContain('~500 videos*')
|
||||
})
|
||||
|
||||
it('applies selected styling when selected', () => {
|
||||
const wrapper = mountOption({ selected: true })
|
||||
expect(wrapper.find('div').classes()).toContain('bg-surface-secondary')
|
||||
expect(wrapper.find('div').classes()).toContain('border-primary')
|
||||
})
|
||||
|
||||
it('applies unselected styling when not selected', () => {
|
||||
const wrapper = mountOption({ selected: false })
|
||||
expect(wrapper.find('div').classes()).toContain('bg-surface-tertiary')
|
||||
expect(wrapper.find('div').classes()).toContain('border-border-primary')
|
||||
expect(wrapper.find('div').classes()).toContain(
|
||||
'bg-component-node-disabled'
|
||||
)
|
||||
expect(wrapper.find('div').classes()).toContain('border-transparent')
|
||||
})
|
||||
|
||||
it('emits select event when clicked', async () => {
|
||||
|
||||
@@ -84,22 +84,22 @@ describe('useSubscriptionCredits', () => {
|
||||
})
|
||||
|
||||
describe('totalCredits', () => {
|
||||
it('should return "0.00 Credits" when balance is null', () => {
|
||||
it('should return "0" when balance is null', () => {
|
||||
authStore.balance = null
|
||||
const { totalCredits } = useSubscriptionCredits()
|
||||
expect(totalCredits.value).toBe('0.00 Credits')
|
||||
expect(totalCredits.value).toBe('0')
|
||||
})
|
||||
|
||||
it('should return "0.00 Credits" when amount_micros is missing', () => {
|
||||
it('should return "0" when amount_micros is missing', () => {
|
||||
authStore.balance = {} as GetCustomerBalanceResponse
|
||||
const { totalCredits } = useSubscriptionCredits()
|
||||
expect(totalCredits.value).toBe('0.00 Credits')
|
||||
expect(totalCredits.value).toBe('0')
|
||||
})
|
||||
|
||||
it('should format amount_micros correctly', () => {
|
||||
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
|
||||
const { totalCredits } = useSubscriptionCredits()
|
||||
expect(totalCredits.value).toBe('211.00 Credits')
|
||||
expect(totalCredits.value).toBe('211')
|
||||
})
|
||||
|
||||
it('should handle formatting errors by throwing', async () => {
|
||||
@@ -116,10 +116,10 @@ describe('useSubscriptionCredits', () => {
|
||||
})
|
||||
|
||||
describe('monthlyBonusCredits', () => {
|
||||
it('should return "0.00 Credits" when cloud_credit_balance_micros is missing', () => {
|
||||
it('should return "0" when cloud_credit_balance_micros is missing', () => {
|
||||
authStore.balance = {} as GetCustomerBalanceResponse
|
||||
const { monthlyBonusCredits } = useSubscriptionCredits()
|
||||
expect(monthlyBonusCredits.value).toBe('0.00 Credits')
|
||||
expect(monthlyBonusCredits.value).toBe('0')
|
||||
})
|
||||
|
||||
it('should format cloud_credit_balance_micros correctly', () => {
|
||||
@@ -127,15 +127,15 @@ describe('useSubscriptionCredits', () => {
|
||||
cloud_credit_balance_micros: 200
|
||||
} as GetCustomerBalanceResponse
|
||||
const { monthlyBonusCredits } = useSubscriptionCredits()
|
||||
expect(monthlyBonusCredits.value).toBe('422.00 Credits')
|
||||
expect(monthlyBonusCredits.value).toBe('422')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepaidCredits', () => {
|
||||
it('should return "0.00 Credits" when prepaid_balance_micros is missing', () => {
|
||||
it('should return "0" when prepaid_balance_micros is missing', () => {
|
||||
authStore.balance = {} as GetCustomerBalanceResponse
|
||||
const { prepaidCredits } = useSubscriptionCredits()
|
||||
expect(prepaidCredits.value).toBe('0.00 Credits')
|
||||
expect(prepaidCredits.value).toBe('0')
|
||||
})
|
||||
|
||||
it('should format prepaid_balance_micros correctly', () => {
|
||||
@@ -143,7 +143,7 @@ describe('useSubscriptionCredits', () => {
|
||||
prepaid_balance_micros: 300
|
||||
} as GetCustomerBalanceResponse
|
||||
const { prepaidCredits } = useSubscriptionCredits()
|
||||
expect(prepaidCredits.value).toBe('633.00 Credits')
|
||||
expect(prepaidCredits.value).toBe('633')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -208,11 +208,6 @@ describe('ImagePreview', () => {
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Simulate image load event to clear loading state
|
||||
const component = wrapper.vm as any
|
||||
component.isLoading = false
|
||||
await nextTick()
|
||||
|
||||
// Now should show second image
|
||||
const imgElement = wrapper.find('img')
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
@@ -265,11 +260,6 @@ describe('ImagePreview', () => {
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Simulate image load event to clear loading state
|
||||
const component = wrapper.vm as any
|
||||
component.isLoading = false
|
||||
await nextTick()
|
||||
|
||||
// Alt text should update
|
||||
const imgElement = wrapper.find('img')
|
||||
expect(imgElement.exists()).toBe(true)
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"ES2023.Array",
|
||||
"ESNext.Iterator",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user