mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-26 07:17:33 +00:00
Compare commits
31 Commits
cloud/1.34
...
test2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8d2b8fad2 | ||
|
|
b52b2bbc30 | ||
|
|
424bd21559 | ||
|
|
04286c033a | ||
|
|
59429cbe56 | ||
|
|
eb04178e33 | ||
|
|
b88d96d6cc | ||
|
|
dedc77786f | ||
|
|
356ebe538f | ||
|
|
2c06c58621 | ||
|
|
c13343b8fb | ||
|
|
ce4837a57c | ||
|
|
2903560416 | ||
|
|
6850c45d63 | ||
|
|
2b9f7ecedf | ||
|
|
73b08acfe0 | ||
|
|
c524ce3a2f | ||
|
|
aef40834f3 | ||
|
|
8209f5a108 | ||
|
|
77e453db36 | ||
|
|
d3e9e15f07 | ||
|
|
4d3f918e8e | ||
|
|
82fc96155f | ||
|
|
79a6421329 | ||
|
|
42314d227f | ||
|
|
8f300c7163 | ||
|
|
5b91434ac4 | ||
|
|
51a336fd36 | ||
|
|
ad630cfbfe | ||
|
|
41eb45754b | ||
|
|
f0a99a0a75 |
57
AGENTS.md
57
AGENTS.md
@@ -73,24 +73,6 @@ The project uses **Nx** for build orchestration and task management
|
||||
- composables `useXyz.ts`
|
||||
- Pinia stores `*Store.ts`
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Frameworks:
|
||||
- Vitest (unit/component, happy-dom)
|
||||
- Playwright (E2E)
|
||||
- Test files:
|
||||
- Unit/Component: `**/*.test.ts`
|
||||
- E2E: `browser_tests/**/*.spec.ts`
|
||||
- Litegraph Specific: `src/lib/litegraph/test/`
|
||||
- Coverage: text/json/html reporters enabled
|
||||
- aim to cover critical logic and new features
|
||||
- Playwright:
|
||||
- optional tags like `@mobile`, `@2x` are respected by config
|
||||
- Tests to avoid
|
||||
- Change detector tests
|
||||
- e.g. a test that just asserts that the defaults are certain values
|
||||
- Tests that are dependent on non-behavioral features like utility classes or styles
|
||||
- Redundant tests
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
@@ -161,7 +143,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
|
||||
15. Do not add or retain redundant comments, clean as you go
|
||||
16. Whenever a new piece of code is written, the author should ask themselves 'is there a simpler way to introduce the same functionality?'. If the answer is yes, the simpler course should be chosen
|
||||
17. Refactoring should be used to make complex code simpler
|
||||
17. [Refactoring](https://refactoring.com/catalog/) should be used to make complex code simpler
|
||||
18. Try to minimize the surface area (exported values) of each module and composable
|
||||
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`
|
||||
20. Keep functions short and functional
|
||||
@@ -170,6 +152,42 @@ The project uses **Nx** for build orchestration and task management
|
||||
23. Favor pure functions (especially testable ones)
|
||||
24. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Frameworks:
|
||||
- Vitest (unit/component, happy-dom)
|
||||
- Playwright (E2E)
|
||||
- Test files:
|
||||
- Unit/Component: `**/*.test.ts`
|
||||
- E2E: `browser_tests/**/*.spec.ts`
|
||||
- Litegraph Specific: `src/lib/litegraph/test/`
|
||||
|
||||
### General
|
||||
|
||||
1. Do not write change detector tests
|
||||
e.g. a test that just asserts that the defaults are certain values
|
||||
2. Do not write tests that are dependent on non-behavioral features like utility classes or styles
|
||||
3. Be parsimonious in testing, do not write redundant tests
|
||||
See <https://tidyfirst.substack.com/p/composable-tests>
|
||||
4. [Don’t Mock What You Don’t Own](https://hynek.me/articles/what-to-mock-in-5-mins/)
|
||||
|
||||
### Vitest / Unit Tests
|
||||
|
||||
1. Do not write tests that just test the mocks
|
||||
Ensure that the tests fail when the code itself would behave in a way that was not expected or desired
|
||||
2. For mocking, leverage [Vitest's utilities](https://vitest.dev/guide/mocking.html) where possible
|
||||
3. Keep your module mocks contained
|
||||
Do not use global mutable state within the test file
|
||||
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
|
||||
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
5. Aim for behavioral coverage of critical and new features
|
||||
|
||||
### Playwright / Browser / E2E Tests
|
||||
|
||||
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
|
||||
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
|
||||
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
|
||||
|
||||
## External Resources
|
||||
|
||||
- Vue: <https://vuejs.org/api/>
|
||||
@@ -182,6 +200,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
- Nx: <https://nx.dev/docs/reference/nx-commands>
|
||||
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
|
||||
@@ -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 |
@@ -50,7 +50,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section shows the release
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Should show the release version
|
||||
@@ -79,7 +79,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section shows no releases
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Should show "No recent releases" message
|
||||
@@ -125,7 +125,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Should show no releases due to error
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(
|
||||
whatsNewSection.locator('text=No recent releases')
|
||||
).toBeVisible()
|
||||
@@ -175,7 +175,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section is hidden
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).not.toBeVisible()
|
||||
|
||||
// Should not show any popups or toasts
|
||||
@@ -260,7 +260,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section is visible
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Should show the release
|
||||
@@ -308,7 +308,7 @@ test.describe('Release Notifications', () => {
|
||||
await helpCenterButton.click()
|
||||
|
||||
// Verify "What's New?" section is visible
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Close help center
|
||||
@@ -359,7 +359,7 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Section should be hidden regardless of empty releases
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
|
||||
await expect(whatsNewSection).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -828,55 +828,55 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
})
|
||||
|
||||
test.describe('Release actions (Shift-drop)', () => {
|
||||
test.fixme(
|
||||
'Context menu opens and endpoint is pinned on Shift-drop',
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
|
||||
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
|
||||
const dropPos = { x: outputCenter.x + 90, y: outputCenter.y - 70 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
)
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
|
||||
comfyPage,
|
||||
|
||||
@@ -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.34.7",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
|
||||
|
||||
--color-coral-red-600: #973a40;
|
||||
--color-coral-red-500: #c53f49;
|
||||
--color-coral-red-400: #dd424e;
|
||||
@@ -183,9 +185,13 @@
|
||||
--interface-menu-component-surface-hovered: var(--color-smoke-200);
|
||||
--interface-menu-component-surface-selected: var(--color-smoke-400);
|
||||
--interface-menu-keybind-surface-default: var(--color-smoke-500);
|
||||
--interface-menu-surface: var(--color-white);
|
||||
--interface-menu-stroke: var(--color-smoke-600);
|
||||
--interface-panel-surface: var(--color-white);
|
||||
--interface-stroke: var(--color-smoke-300);
|
||||
|
||||
|
||||
|
||||
--nav-background: var(--color-white);
|
||||
|
||||
--node-border: var(--color-smoke-300);
|
||||
@@ -301,6 +307,8 @@
|
||||
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
|
||||
--interface-menu-component-surface-selected: var(--color-charcoal-300);
|
||||
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
|
||||
--interface-menu-surface: var(--color-charcoal-800);
|
||||
--interface-menu-stroke: var(--color-ash-800);
|
||||
--interface-panel-surface: var(--color-charcoal-800);
|
||||
--interface-stroke: var(--color-charcoal-400);
|
||||
|
||||
@@ -416,6 +424,8 @@
|
||||
--color-interface-menu-keybind-surface-default: var(
|
||||
--interface-menu-keybind-surface-default
|
||||
);
|
||||
--color-interface-menu-surface: var(--interface-menu-surface);
|
||||
--color-interface-menu-stroke: var(--interface-menu-stroke);
|
||||
--color-interface-panel-surface: var(--interface-panel-surface);
|
||||
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
|
||||
--color-interface-panel-selected-surface: var(
|
||||
|
||||
308
packages/registry-types/src/comfyRegistryTypes.ts
generated
308
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -1945,40 +1945,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/kling/v1/images/omni-image": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** KlingAI Create Omni-Image Task */
|
||||
post: operations["klingCreateOmniImage"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/kling/v1/images/omni-image/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** KlingAI Query Single Omni-Image Task */
|
||||
get: operations["klingOmniImageQuerySingleTask"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/kling/v1/images/kolors-virtual-try-on": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3910,7 +3876,7 @@ export interface components {
|
||||
* @description The subscription tier level
|
||||
* @enum {string}
|
||||
*/
|
||||
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO";
|
||||
FeaturesResponse: {
|
||||
/**
|
||||
* @description The conversion rate for partner nodes
|
||||
@@ -5130,71 +5096,6 @@ export interface components {
|
||||
};
|
||||
};
|
||||
};
|
||||
KlingOmniImageRequest: {
|
||||
/**
|
||||
* @description Model Name
|
||||
* @default kling-image-o1
|
||||
* @enum {string}
|
||||
*/
|
||||
model_name: "kling-image-o1";
|
||||
/** @description Text prompt words, which can include positive and negative descriptions. Must not exceed 2,500 characters. The Omni model can achieve various capabilities through Prompt with elements and images. Specify an image in the format of <<<>>>, such as <<<image_1>>>. */
|
||||
prompt: string;
|
||||
/** @description Reference Image List. Supports inputting image Base64 encoding or image URL (ensure accessibility). Supported formats include .jpg/.jpeg/.png. File size cannot exceed 10MB. Width and height dimensions shall not be less than 300px, aspect ratio between 1:2.5 ~ 2.5:1. Maximum 10 images. */
|
||||
image_list?: {
|
||||
/** @description Image Base64 encoding or image URL (ensure accessibility) */
|
||||
image?: string;
|
||||
}[];
|
||||
/**
|
||||
* @description Image generation resolution. 1k is 1K standard, 2k is 2K high-res, 4k is 4K high-res.
|
||||
* @default 1k
|
||||
* @enum {string}
|
||||
*/
|
||||
resolution: "1k" | "2k" | "4k";
|
||||
/**
|
||||
* @description Number of generated images. Value range [1,9].
|
||||
* @default 1
|
||||
*/
|
||||
n: number;
|
||||
/**
|
||||
* @description Aspect ratio of the generated images (width:height). auto is to intelligently generate images based on incoming content.
|
||||
* @default auto
|
||||
* @enum {string}
|
||||
*/
|
||||
aspect_ratio: "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "21:9" | "auto";
|
||||
/**
|
||||
* Format: uri
|
||||
* @description The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes.
|
||||
*/
|
||||
callback_url?: string;
|
||||
/** @description Customized Task ID. Must be unique within a single user account. */
|
||||
external_task_id?: string;
|
||||
};
|
||||
KlingOmniImageResponse: {
|
||||
/** @description Error code */
|
||||
code?: number;
|
||||
/** @description Error message */
|
||||
message?: string;
|
||||
/** @description Request ID */
|
||||
request_id?: string;
|
||||
data?: {
|
||||
/** @description Task ID */
|
||||
task_id?: string;
|
||||
task_status?: components["schemas"]["KlingTaskStatus"];
|
||||
/** @description Task status information, displaying the failure reason when the task fails (such as triggering the content risk control of the platform, etc.) */
|
||||
task_status_msg?: string;
|
||||
task_info?: {
|
||||
/** @description Customer-defined task ID */
|
||||
external_task_id?: string;
|
||||
};
|
||||
/** @description Task creation time, Unix timestamp in milliseconds */
|
||||
created_at?: number;
|
||||
/** @description Task update time, Unix timestamp in milliseconds */
|
||||
updated_at?: number;
|
||||
task_result?: {
|
||||
images?: components["schemas"]["KlingImageResult"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
KlingLipSyncInputObject: {
|
||||
/** @description The ID of the video generated by Kling AI. Only supports 5-second and 10-second videos generated within the last 30 days. */
|
||||
video_id?: string;
|
||||
@@ -10164,7 +10065,7 @@ export interface components {
|
||||
};
|
||||
BytePlusImageGenerationRequest: {
|
||||
/** @enum {string} */
|
||||
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828" | "seedream-4-5-251128";
|
||||
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828";
|
||||
/** @description Text description for image generation or transformation */
|
||||
prompt: string;
|
||||
/**
|
||||
@@ -10269,10 +10170,10 @@ export interface components {
|
||||
};
|
||||
BytePlusVideoGenerationRequest: {
|
||||
/**
|
||||
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-pro-fast-251015, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
|
||||
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
|
||||
* @enum {string}
|
||||
*/
|
||||
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428" | "seedance-1-0-pro-fast-251015";
|
||||
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428";
|
||||
/** @description The input content for the model to generate a video */
|
||||
content: components["schemas"]["BytePlusVideoGenerationContent"][];
|
||||
/**
|
||||
@@ -14046,15 +13947,6 @@ export interface operations {
|
||||
"application/json": components["schemas"]["Node"];
|
||||
};
|
||||
};
|
||||
/** @description Redirect to node with normalized name match */
|
||||
302: {
|
||||
headers: {
|
||||
/** @description URL of the node with the correct ID */
|
||||
Location?: string;
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
@@ -18453,198 +18345,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
klingCreateOmniImage: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Create task for generating omni-image */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingOmniImageRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful response (Request successful) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingOmniImageResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid request parameters */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication failed */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized access to requested resource */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Account exception or Rate limit exceeded */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Service temporarily unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Server timeout */
|
||||
504: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
klingOmniImageQuerySingleTask: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Task ID or External Task ID. Can query by either task_id (generated by system) or external_task_id (customized task ID) */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful response (Request successful) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingOmniImageResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid request parameters */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication failed */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized access to requested resource */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Account exception or Rate limit exceeded */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Service temporarily unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Server timeout */
|
||||
504: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["KlingErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
klingVirtualTryOnQueryTaskList: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -134,22 +134,38 @@ function resolveRelease(
|
||||
|
||||
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
|
||||
|
||||
// Calculate target minor version (next minor)
|
||||
const targetMinor = currentMinor + 1
|
||||
const targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
// Check if target branch exists in frontend repo
|
||||
// Fetch all branches
|
||||
exec('git fetch origin', frontendRepoPath)
|
||||
const branchExists = exec(
|
||||
|
||||
// Try next minor first, fall back to current minor if not available
|
||||
let targetMinor = currentMinor + 1
|
||||
let targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
const nextMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!branchExists) {
|
||||
console.error(
|
||||
`Target branch ${targetBranch} does not exist in frontend repo`
|
||||
if (!nextMinorExists) {
|
||||
// Fall back to current minor for patch releases
|
||||
targetMinor = currentMinor
|
||||
targetBranch = `core/1.${targetMinor}`
|
||||
|
||||
const currentMinorExists = exec(
|
||||
`git rev-parse --verify origin/${targetBranch}`,
|
||||
frontendRepoPath
|
||||
)
|
||||
|
||||
if (!currentMinorExists) {
|
||||
console.error(
|
||||
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for patch release`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Get latest patch tag for target minor
|
||||
|
||||
@@ -20,17 +20,6 @@
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="mr-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
@@ -87,8 +76,6 @@ import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -96,8 +83,6 @@ import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
@@ -108,13 +93,9 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('rightSidePanel.togglePanel'))
|
||||
)
|
||||
@@ -131,11 +112,6 @@ onMounted(() => {
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
}
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
/>
|
||||
|
||||
<ComfyRunButton />
|
||||
<IconButton
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
@@ -43,17 +54,24 @@ import {
|
||||
watchDebounced
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
@@ -250,6 +268,16 @@ watch(isDragging, (dragging) => {
|
||||
isMouseOverDropZone.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
'w-[200px] border-dashed border-blue-500 opacity-80',
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
class="bg-transparent"
|
||||
>
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="tabs-container">
|
||||
<div class="tabs-container font-inter">
|
||||
<Tab
|
||||
v-for="tab in bottomPanelStore.bottomPanelTabs"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
class="m-1 mx-2 border-none"
|
||||
class="m-1 mx-2 border-none font-inter"
|
||||
:class="{
|
||||
'tab-list-single-item':
|
||||
bottomPanelStore.bottomPanelTabs.length === 1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Tree
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:selection-keys="selectionKeys"
|
||||
class="tree-explorer px-2 py-0 2xl:px-4"
|
||||
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
|
||||
:class="props.class"
|
||||
:value="renderedRoot.children"
|
||||
selection-mode="single"
|
||||
|
||||
@@ -64,8 +64,7 @@ const formattedCreditsOnly = computed(() => {
|
||||
const cents = authStore.balance?.amount_micros ?? 0
|
||||
const amount = formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
|
||||
locale: locale.value
|
||||
})
|
||||
return amount
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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-base-foreground m-0">
|
||||
<h1 class="text-2xl font-semibold text-white m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
@@ -62,7 +62,7 @@
|
||||
severity="primary"
|
||||
:label="$t('credits.topUp.buy')"
|
||||
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
|
||||
:pt="{ label: { class: 'text-primary-foreground' } }"
|
||||
:pt="{ label: { class: 'text-white' } }"
|
||||
@click="handleBuy"
|
||||
/>
|
||||
</div>
|
||||
@@ -122,7 +122,11 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
creditsToUsd,
|
||||
formatCredits,
|
||||
formatUsd
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
@@ -152,7 +156,7 @@ const { formattedRenewalDate } = useSubscription()
|
||||
// Use feature flag to determine design - defaults to true (new design)
|
||||
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
@@ -187,6 +191,19 @@ const handleBuy = async () => {
|
||||
const usdAmount = creditsToUsd(selectedCredits.value)
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
|
||||
await authActions.purchaseCredits(usdAmount)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('credits.topUp.purchaseSuccess'),
|
||||
detail: t('credits.topUp.purchaseSuccessDetail', {
|
||||
credits: formatCredits({
|
||||
value: selectedCredits.value,
|
||||
locale: locale.value
|
||||
}),
|
||||
amount: `$${formatUsd({ value: usdAmount, locale: locale.value })}`
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
]"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<span class="text-base font-bold text-base-foreground">
|
||||
<span class="text-base font-bold text-white">
|
||||
{{ formattedCredits }}
|
||||
</span>
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
<span class="text-sm font-normal text-white">
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
<template>
|
||||
<div
|
||||
class="help-center-menu"
|
||||
class="help-center-menu flex flex-col items-start gap-1"
|
||||
role="menu"
|
||||
:aria-label="$t('helpCenter.helpFeedback')"
|
||||
:aria-label="$t('help.helpCenterMenu')"
|
||||
>
|
||||
<!-- Main Menu Items -->
|
||||
<nav class="help-menu-section" role="menubar">
|
||||
<button
|
||||
v-for="menuItem in menuItems"
|
||||
v-show="menuItem.visible !== false"
|
||||
:key="menuItem.key"
|
||||
type="button"
|
||||
class="help-menu-item"
|
||||
:class="{ 'more-item': menuItem.key === 'more' }"
|
||||
role="menuitem"
|
||||
@click="menuItem.action"
|
||||
@mouseenter="onMenuItemHover(menuItem.key, $event)"
|
||||
@mouseleave="onMenuItemLeave(menuItem.key)"
|
||||
>
|
||||
<div class="help-menu-icon-container">
|
||||
<div class="help-menu-icon">
|
||||
<component
|
||||
:is="menuItem.icon"
|
||||
v-if="typeof menuItem.icon === 'object'"
|
||||
:size="16"
|
||||
/>
|
||||
<i v-else :class="menuItem.icon" />
|
||||
<div class="w-full">
|
||||
<nav class="flex w-full flex-col gap-2" role="menubar">
|
||||
<button
|
||||
v-for="menuItem in menuItems"
|
||||
v-show="menuItem.visible !== false"
|
||||
:key="menuItem.key"
|
||||
type="button"
|
||||
class="help-menu-item"
|
||||
:class="{ 'more-item': menuItem.key === 'more' }"
|
||||
role="menuitem"
|
||||
@click="menuItem.action"
|
||||
@mouseenter="onMenuItemHover(menuItem.key, $event)"
|
||||
@mouseleave="onMenuItemLeave(menuItem.key)"
|
||||
>
|
||||
<div class="help-menu-icon-container">
|
||||
<div class="help-menu-icon">
|
||||
<component
|
||||
:is="menuItem.icon"
|
||||
v-if="typeof menuItem.icon === 'object'"
|
||||
:size="16"
|
||||
/>
|
||||
<i v-else :class="menuItem.icon" />
|
||||
</div>
|
||||
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
|
||||
</div>
|
||||
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
|
||||
</div>
|
||||
<span class="menu-label">{{ menuItem.label }}</span>
|
||||
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
|
||||
</button>
|
||||
</nav>
|
||||
<span class="menu-label">{{ menuItem.label }}</span>
|
||||
<i
|
||||
v-if="menuItem.showExternalIcon"
|
||||
class="icon-[lucide--external-link] text-primary w-4 h-4 ml-auto"
|
||||
/>
|
||||
<i
|
||||
v-if="menuItem.key === 'more'"
|
||||
class="pi pi-chevron-right ml-auto"
|
||||
/>
|
||||
</button>
|
||||
</nav>
|
||||
<div
|
||||
class="flex h-4 flex-col items-center justify-between self-stretch p-2"
|
||||
>
|
||||
<div class="w-full border-b border-interface-menu-stroke" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- More Submenu -->
|
||||
<Teleport to="body">
|
||||
@@ -68,26 +82,34 @@
|
||||
</Teleport>
|
||||
|
||||
<!-- What's New Section -->
|
||||
<section v-if="showVersionUpdates" class="whats-new-section">
|
||||
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
|
||||
<section
|
||||
v-if="showVersionUpdates"
|
||||
class="w-full"
|
||||
data-testid="whats-new-section"
|
||||
>
|
||||
<h3
|
||||
class="section-description flex items-center gap-2.5 self-stretch px-8 pt-2 pb-2"
|
||||
>
|
||||
{{ $t('helpCenter.whatsNew') }}
|
||||
</h3>
|
||||
|
||||
<!-- Release Items -->
|
||||
<div
|
||||
v-if="hasReleases"
|
||||
role="group"
|
||||
:aria-label="$t('helpCenter.recentReleases')"
|
||||
:aria-label="$t('help.recentReleases')"
|
||||
>
|
||||
<article
|
||||
v-for="release in releaseStore.recentReleases"
|
||||
:key="release.id || release.version"
|
||||
class="help-menu-item release-menu-item"
|
||||
class="release-menu-item flex h-12 min-h-6 cursor-pointer items-center gap-2 self-stretch rounded p-2 transition-colors hover:bg-interface-menu-component-surface-hovered"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onReleaseClick(release)"
|
||||
@keydown.enter="onReleaseClick(release)"
|
||||
@keydown.space.prevent="onReleaseClick(release)"
|
||||
>
|
||||
<i class="pi pi-refresh help-menu-icon" aria-hidden="true" />
|
||||
<i class="help-menu-icon icon-[lucide--package]" aria-hidden="true" />
|
||||
<div class="release-content">
|
||||
<span class="release-title">
|
||||
{{
|
||||
@@ -106,13 +128,6 @@
|
||||
</span>
|
||||
</time>
|
||||
</div>
|
||||
<Button
|
||||
v-if="shouldShowUpdateButton(release)"
|
||||
:label="$t('helpCenter.updateAvailable')"
|
||||
size="small"
|
||||
class="update-button"
|
||||
@click.stop="onUpdate(release)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -137,7 +152,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { CSSProperties, Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -166,6 +180,7 @@ interface MenuItem {
|
||||
type?: 'item' | 'divider'
|
||||
items?: MenuItem[]
|
||||
showRedDot?: boolean
|
||||
showExternalIcon?: boolean
|
||||
}
|
||||
|
||||
// Constants
|
||||
@@ -192,6 +207,9 @@ const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
// Track when help center was opened
|
||||
const openedAt = ref(Date.now())
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@@ -202,7 +220,6 @@ const isSubmenuVisible = ref(false)
|
||||
const submenuRef = ref<HTMLElement | null>(null)
|
||||
const submenuStyle = ref<CSSProperties>({})
|
||||
let hoverTimeout: number | null = null
|
||||
const openedAt = ref<number>(Date.now())
|
||||
|
||||
// Computed
|
||||
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
||||
@@ -273,11 +290,34 @@ const moreMenuItem = computed(() =>
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'feedback',
|
||||
type: 'item',
|
||||
icon: 'icon-[lucide--clipboard-pen]',
|
||||
label: t('helpCenter.feedback'),
|
||||
action: () => {
|
||||
trackResourceClick('help_feedback', false)
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'help',
|
||||
type: 'item',
|
||||
icon: 'icon-[lucide--message-circle-question]',
|
||||
label: t('helpCenter.help'),
|
||||
action: () => {
|
||||
trackResourceClick('help_feedback', false)
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'docs',
|
||||
type: 'item',
|
||||
icon: 'pi pi-book',
|
||||
icon: 'icon-[lucide--book-open]',
|
||||
label: t('helpCenter.docs'),
|
||||
showExternalIcon: true,
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
const path = isCloud ? '/get_started/cloud' : '/'
|
||||
@@ -290,6 +330,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
type: 'item',
|
||||
icon: 'pi pi-discord',
|
||||
label: 'Discord',
|
||||
showExternalIcon: true,
|
||||
action: () => {
|
||||
trackResourceClick('discord', true)
|
||||
openExternalLink(staticUrls.discord)
|
||||
@@ -299,24 +340,14 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
{
|
||||
key: 'github',
|
||||
type: 'item',
|
||||
icon: 'pi pi-github',
|
||||
icon: 'icon-[lucide--github]',
|
||||
label: t('helpCenter.github'),
|
||||
showExternalIcon: true,
|
||||
action: () => {
|
||||
trackResourceClick('github', true)
|
||||
openExternalLink(staticUrls.github)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'help',
|
||||
type: 'item',
|
||||
icon: 'pi pi-question-circle',
|
||||
label: t('helpCenter.helpFeedback'),
|
||||
action: () => {
|
||||
trackResourceClick('help_feedback', false)
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -438,32 +469,22 @@ const formatReleaseDate = (dateString?: string): string => {
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime())
|
||||
|
||||
const timeUnits = [
|
||||
{ unit: TIME_UNITS.YEAR, suffix: 'y' },
|
||||
{ unit: TIME_UNITS.MONTH, suffix: 'mo' },
|
||||
{ unit: TIME_UNITS.WEEK, suffix: 'w' },
|
||||
{ unit: TIME_UNITS.DAY, suffix: 'd' },
|
||||
{ unit: TIME_UNITS.HOUR, suffix: 'h' },
|
||||
{ unit: TIME_UNITS.MINUTE, suffix: 'min' }
|
||||
{ unit: TIME_UNITS.YEAR, key: 'yearsAgo' },
|
||||
{ unit: TIME_UNITS.MONTH, key: 'monthsAgo' },
|
||||
{ unit: TIME_UNITS.WEEK, key: 'weeksAgo' },
|
||||
{ unit: TIME_UNITS.DAY, key: 'daysAgo' },
|
||||
{ unit: TIME_UNITS.HOUR, key: 'hoursAgo' },
|
||||
{ unit: TIME_UNITS.MINUTE, key: 'minutesAgo' }
|
||||
]
|
||||
|
||||
for (const { unit, suffix } of timeUnits) {
|
||||
for (const { unit, key } of timeUnits) {
|
||||
const value = Math.floor(diffTime / unit)
|
||||
if (value > 0) {
|
||||
return `${value}${suffix} ago`
|
||||
return t(`g.relativeTime.${key}`, { count: value })
|
||||
}
|
||||
}
|
||||
|
||||
return 'now'
|
||||
}
|
||||
|
||||
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
|
||||
// Hide update buttons in cloud distribution
|
||||
if (isCloud) return false
|
||||
|
||||
return (
|
||||
releaseStore.shouldShowUpdateButton &&
|
||||
release === releaseStore.recentReleases[0]
|
||||
)
|
||||
return t('g.relativeTime.now')
|
||||
}
|
||||
|
||||
// Event Handlers
|
||||
@@ -533,14 +554,6 @@ const onReleaseClick = (release: ReleaseNote): void => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const onUpdate = (_: ReleaseNote): void => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(
|
||||
buildDocsUrl('/installation/update_comfyui', { includeLocale: true })
|
||||
)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
|
||||
@@ -557,38 +570,37 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.help-center-menu {
|
||||
width: 380px;
|
||||
width: 256px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: var(--p-content-background);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgb(0 0 0 / 0.15);
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
backdrop-filter: blur(8px);
|
||||
background: var(--interface-menu-surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 0.1);
|
||||
border: 1px solid var(--interface-menu-stroke);
|
||||
padding: 12px 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-menu-section {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--p-content-border-color);
|
||||
}
|
||||
|
||||
.help-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
height: 32px;
|
||||
min-height: 24px;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 0.9rem;
|
||||
color: inherit;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.help-menu-item:hover {
|
||||
background-color: #007aff26;
|
||||
background-color: var(--interface-menu-component-surface-hovered);
|
||||
}
|
||||
|
||||
.help-menu-item:focus,
|
||||
@@ -599,16 +611,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
.help-menu-icon-container {
|
||||
position: relative;
|
||||
margin-right: 0.75rem;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-menu-icon {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1rem;
|
||||
color: var(--p-text-muted-color);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -616,7 +628,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.help-menu-icon svg {
|
||||
color: var(--p-text-muted-color);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.menu-red-dot {
|
||||
@@ -639,16 +653,14 @@ onBeforeUnmount(() => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.whats-new-section {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-text-muted-color);
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-inter);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: normal;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -661,7 +673,7 @@ onBeforeUnmount(() => {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -669,12 +681,22 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.release-date {
|
||||
height: 16px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--p-text-muted-color);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-inter);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.release-date .hover-state {
|
||||
@@ -691,35 +713,31 @@ onBeforeUnmount(() => {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.update-button {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Submenu Styles */
|
||||
.more-submenu {
|
||||
width: 210px;
|
||||
padding: 0.5rem 0;
|
||||
background: var(--p-content-background);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
box-shadow: 0 8px 32px rgb(0 0 0 / 0.15);
|
||||
padding: 12px 8px;
|
||||
background: var(--interface-menu-surface);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--interface-menu-stroke);
|
||||
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 0.1);
|
||||
overflow: hidden;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
padding: 0.75rem 1rem;
|
||||
color: inherit;
|
||||
padding: 8px;
|
||||
height: 32px;
|
||||
min-height: 24px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.submenu-item:hover {
|
||||
background-color: #007aff26;
|
||||
background-color: var(--interface-menu-component-surface-hovered);
|
||||
}
|
||||
|
||||
.submenu-item:focus,
|
||||
@@ -730,8 +748,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
.submenu-divider {
|
||||
height: 1px;
|
||||
background: #3e3e3e;
|
||||
margin: 0.5rem 0;
|
||||
background: var(--interface-menu-stroke);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
@@ -744,12 +762,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.help-center-menu::-webkit-scrollbar-thumb {
|
||||
background: var(--p-content-border-color);
|
||||
background: var(--interface-menu-stroke);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.help-center-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--p-text-muted-color);
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
|
||||
@@ -182,7 +182,7 @@ function handleTitleCancel() {
|
||||
<Tab
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="text-sm py-1 px-2"
|
||||
class="text-sm py-1 px-2 font-inter"
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col bg-interface-panel-surface"
|
||||
:class="props.class"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
v-if="slots.top"
|
||||
class="flex min-h-12 items-center border-b border-interface-stroke px-4 py-2"
|
||||
>
|
||||
<slot name="top" />
|
||||
</div>
|
||||
<div v-if="slots.header" class="px-4 pb-4">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- min-h-0 to force scrollpanel to grow -->
|
||||
<ScrollPanel class="min-h-0 grow">
|
||||
<slot name="body" />
|
||||
</ScrollPanel>
|
||||
<div v-if="slots.footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useSlots } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
</script>
|
||||
@@ -1,19 +1,21 @@
|
||||
<template>
|
||||
<AssetsSidebarTemplate>
|
||||
<template #top>
|
||||
<span v-if="!isInFolderView" class="font-bold">
|
||||
{{ $t('sideToolbar.mediaAssets.title') }}
|
||||
</span>
|
||||
<div v-else class="flex w-full items-center justify-between gap-2">
|
||||
<SidebarTabTemplate
|
||||
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
|
||||
>
|
||||
<template #alt-title>
|
||||
<div
|
||||
v-if="isInFolderView"
|
||||
class="flex w-full items-center justify-between gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ $t('Job ID') }}:</span>
|
||||
<span class="font-bold">{{ $t('assetBrowser.jobId') }}:</span>
|
||||
<span class="text-sm">{{ folderPromptId?.substring(0, 8) }}</span>
|
||||
<button
|
||||
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
|
||||
role="button"
|
||||
@click="copyJobId"
|
||||
>
|
||||
<i class="mb-1 icon-[lucide--copy] text-sm"></i>
|
||||
<i class="icon-[lucide--copy] text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
@@ -23,7 +25,7 @@
|
||||
</template>
|
||||
<template #header>
|
||||
<!-- Job Detail View Header -->
|
||||
<div v-if="isInFolderView" class="pt-4 pb-2">
|
||||
<div v-if="isInFolderView" class="px-2 2xl:px-4">
|
||||
<IconTextButton
|
||||
:label="$t('sideToolbar.backToAssets')"
|
||||
type="secondary"
|
||||
@@ -35,15 +37,20 @@
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<!-- Normal Tab View -->
|
||||
<TabList v-else v-model="activeTab" class="pt-4 pb-1">
|
||||
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||
<TabList v-else v-model="activeTab" class="font-inter px-2 2xl:px-4">
|
||||
<Tab class="font-inter" value="output">{{
|
||||
$t('sideToolbar.labels.generated')
|
||||
}}</Tab>
|
||||
<Tab class="font-inter" value="input">{{
|
||||
$t('sideToolbar.labels.imported')
|
||||
}}</Tab>
|
||||
</TabList>
|
||||
<!-- Filter Bar -->
|
||||
<MediaAssetFilterBar
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
class="pb-1 px-2 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
</template>
|
||||
@@ -158,7 +165,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AssetsSidebarTemplate>
|
||||
</SidebarTabTemplate>
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
@@ -177,6 +184,7 @@ import TextButton from '@/components/button/TextButton.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
@@ -194,8 +202,6 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="$t('sideToolbar.modelLibrary')"
|
||||
class="bg-(--p-tree-background)"
|
||||
>
|
||||
<SidebarTabTemplate :title="$t('sideToolbar.modelLibrary')">
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.refresh')"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<SidebarTabTemplate
|
||||
v-if="!isHelpOpen"
|
||||
:title="$t('sideToolbar.nodeLibrary')"
|
||||
class="bg-(--p-tree-background)"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
class="comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col"
|
||||
:class="props.class"
|
||||
>
|
||||
<div class="comfy-vue-side-bar-header">
|
||||
<Toolbar class="min-h-8 rounded-none border-x-0 border-t-0 px-2 py-1">
|
||||
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
|
||||
<Toolbar
|
||||
class="min-h-15.5 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
|
||||
>
|
||||
<template #start>
|
||||
<span class="truncate text-xs 2xl:text-sm" :title="props.title">
|
||||
{{ props.title.toUpperCase() }}
|
||||
<span class="truncate font-bold" :title="props.title">
|
||||
{{ props.title }}
|
||||
</span>
|
||||
<slot name="alt-title" />
|
||||
</template>
|
||||
<template #end>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="$t('sideToolbar.workflows')"
|
||||
class="workflows-sidebar-tab bg-(--p-tree-background)"
|
||||
class="workflows-sidebar-tab"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-auto bg-(--p-tree-background)">
|
||||
<div class="flex h-full flex-col overflow-auto">
|
||||
<div
|
||||
class="flex items-center border-b border-(--p-divider-color) px-3 py-2"
|
||||
>
|
||||
|
||||
@@ -21,21 +21,12 @@
|
||||
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
<p v-if="subscriptionTierName" class="my-0 truncate text-sm text-muted">
|
||||
{{ subscriptionTierName }}
|
||||
</p>
|
||||
</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" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="flex-1"
|
||||
/>
|
||||
<span v-else class="text-base font-normal text-base-foreground flex-1">{{
|
||||
<span class="text-base font-normal text-base-foreground flex-1">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<Button
|
||||
@@ -48,15 +39,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex justify-center px-4">
|
||||
<SubscribeButton
|
||||
:fluid="false"
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="small"
|
||||
variant="gradient"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
</div>
|
||||
<SubscribeButton
|
||||
v-else
|
||||
class="mx-4"
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="small"
|
||||
variant="gradient"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
|
||||
<!-- Credits info row -->
|
||||
<div
|
||||
@@ -131,7 +121,6 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -163,8 +152,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, subscriptionTierName, fetchStatus } =
|
||||
useSubscription()
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { locale } = useI18n()
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -40,12 +40,12 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget) return '$0.0715/second'
|
||||
if (!durationWidget) return '$0.05/second'
|
||||
|
||||
const duration = Number(durationWidget.value)
|
||||
// If duration is 0 or NaN, don't fall back to 5 seconds - just use 0
|
||||
const validDuration = isNaN(duration) ? 5 : duration
|
||||
const cost = (0.0715 * validDuration).toFixed(2)
|
||||
const cost = (0.05 * validDuration).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
@@ -377,11 +377,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
(w) => w.name === 'turbo'
|
||||
) as IComboWidget
|
||||
|
||||
if (!numImagesWidget) return '$0.03-0.09 x num_images/Run'
|
||||
if (!numImagesWidget) return '$0.02-0.06 x num_images/Run'
|
||||
|
||||
const numImages = Number(numImagesWidget.value) || 1
|
||||
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
|
||||
const basePrice = turbo ? 0.0286 : 0.0858
|
||||
const basePrice = turbo ? 0.02 : 0.06
|
||||
const cost = (basePrice * numImages).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
@@ -395,11 +395,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
(w) => w.name === 'turbo'
|
||||
) as IComboWidget
|
||||
|
||||
if (!numImagesWidget) return '$0.07-0.11 x num_images/Run'
|
||||
if (!numImagesWidget) return '$0.05-0.08 x num_images/Run'
|
||||
|
||||
const numImages = Number(numImagesWidget.value) || 1
|
||||
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
|
||||
const basePrice = turbo ? 0.0715 : 0.1144
|
||||
const basePrice = turbo ? 0.05 : 0.08
|
||||
const cost = (basePrice * numImages).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
@@ -420,29 +420,29 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
characterInput.link != null
|
||||
|
||||
if (!renderingSpeedWidget)
|
||||
return '$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)'
|
||||
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
|
||||
|
||||
const numImages = Number(numImagesWidget?.value) || 1
|
||||
let basePrice = 0.0858 // default balanced price
|
||||
let basePrice = 0.06 // default balanced price
|
||||
|
||||
const renderingSpeed = String(renderingSpeedWidget.value)
|
||||
if (renderingSpeed.toLowerCase().includes('quality')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.286
|
||||
basePrice = 0.2
|
||||
} else {
|
||||
basePrice = 0.1287
|
||||
basePrice = 0.09
|
||||
}
|
||||
} else if (renderingSpeed.toLowerCase().includes('default')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.2145
|
||||
basePrice = 0.15
|
||||
} else {
|
||||
basePrice = 0.0858
|
||||
basePrice = 0.06
|
||||
}
|
||||
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.143
|
||||
basePrice = 0.1
|
||||
} else {
|
||||
basePrice = 0.0429
|
||||
basePrice = 0.03
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,7 +755,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !resolutionWidget || !durationWidget) {
|
||||
return '$0.20-16.40/Run (varies with model, resolution & duration)'
|
||||
return '$0.14-11.47/Run (varies with model, resolution & duration)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
@@ -764,33 +764,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
if (model.includes('ray-flash-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$3.13/Run'
|
||||
if (resolution.includes('1080p')) return '$0.79/Run'
|
||||
if (resolution.includes('720p')) return '$0.34/Run'
|
||||
if (resolution.includes('540p')) return '$0.20/Run'
|
||||
if (resolution.includes('4k')) return '$2.19/Run'
|
||||
if (resolution.includes('1080p')) return '$0.55/Run'
|
||||
if (resolution.includes('720p')) return '$0.24/Run'
|
||||
if (resolution.includes('540p')) return '$0.14/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$5.65/Run'
|
||||
if (resolution.includes('1080p')) return '$1.42/Run'
|
||||
if (resolution.includes('720p')) return '$0.61/Run'
|
||||
if (resolution.includes('540p')) return '$0.36/Run'
|
||||
if (resolution.includes('4k')) return '$3.95/Run'
|
||||
if (resolution.includes('1080p')) return '$0.99/Run'
|
||||
if (resolution.includes('720p')) return '$0.43/Run'
|
||||
if (resolution.includes('540p')) return '$0.252/Run'
|
||||
}
|
||||
} else if (model.includes('ray-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$9.11/Run'
|
||||
if (resolution.includes('1080p')) return '$2.27/Run'
|
||||
if (resolution.includes('720p')) return '$1.02/Run'
|
||||
if (resolution.includes('540p')) return '$0.57/Run'
|
||||
if (resolution.includes('4k')) return '$6.37/Run'
|
||||
if (resolution.includes('1080p')) return '$1.59/Run'
|
||||
if (resolution.includes('720p')) return '$0.71/Run'
|
||||
if (resolution.includes('540p')) return '$0.40/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$16.40/Run'
|
||||
if (resolution.includes('1080p')) return '$4.10/Run'
|
||||
if (resolution.includes('720p')) return '$1.83/Run'
|
||||
if (resolution.includes('540p')) return '$1.03/Run'
|
||||
if (resolution.includes('4k')) return '$11.47/Run'
|
||||
if (resolution.includes('1080p')) return '$2.87/Run'
|
||||
if (resolution.includes('720p')) return '$1.28/Run'
|
||||
if (resolution.includes('540p')) return '$0.72/Run'
|
||||
}
|
||||
} else if (model.includes('ray-1-6')) {
|
||||
return '$0.50/Run'
|
||||
} else if (model.includes('ray-1.6')) {
|
||||
return '$0.35/Run'
|
||||
}
|
||||
|
||||
return '$0.79/Run'
|
||||
return '$0.55/Run'
|
||||
}
|
||||
},
|
||||
LumaVideoNode: {
|
||||
@@ -806,7 +806,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !resolutionWidget || !durationWidget) {
|
||||
return '$0.20-16.40/Run (varies with model, resolution & duration)'
|
||||
return '$0.14-11.47/Run (varies with model, resolution & duration)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
@@ -815,33 +815,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
if (model.includes('ray-flash-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$3.13/Run'
|
||||
if (resolution.includes('1080p')) return '$0.79/Run'
|
||||
if (resolution.includes('720p')) return '$0.34/Run'
|
||||
if (resolution.includes('540p')) return '$0.20/Run'
|
||||
if (resolution.includes('4k')) return '$2.19/Run'
|
||||
if (resolution.includes('1080p')) return '$0.55/Run'
|
||||
if (resolution.includes('720p')) return '$0.24/Run'
|
||||
if (resolution.includes('540p')) return '$0.14/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$5.65/Run'
|
||||
if (resolution.includes('1080p')) return '$1.42/Run'
|
||||
if (resolution.includes('720p')) return '$0.61/Run'
|
||||
if (resolution.includes('540p')) return '$0.36/Run'
|
||||
if (resolution.includes('4k')) return '$3.95/Run'
|
||||
if (resolution.includes('1080p')) return '$0.99/Run'
|
||||
if (resolution.includes('720p')) return '$0.43/Run'
|
||||
if (resolution.includes('540p')) return '$0.252/Run'
|
||||
}
|
||||
} else if (model.includes('ray-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$9.11/Run'
|
||||
if (resolution.includes('1080p')) return '$2.27/Run'
|
||||
if (resolution.includes('720p')) return '$1.02/Run'
|
||||
if (resolution.includes('540p')) return '$0.57/Run'
|
||||
if (resolution.includes('4k')) return '$6.37/Run'
|
||||
if (resolution.includes('1080p')) return '$1.59/Run'
|
||||
if (resolution.includes('720p')) return '$0.71/Run'
|
||||
if (resolution.includes('540p')) return '$0.40/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$16.40/Run'
|
||||
if (resolution.includes('1080p')) return '$4.10/Run'
|
||||
if (resolution.includes('720p')) return '$1.83/Run'
|
||||
if (resolution.includes('540p')) return '$1.03/Run'
|
||||
if (resolution.includes('4k')) return '$11.47/Run'
|
||||
if (resolution.includes('1080p')) return '$2.87/Run'
|
||||
if (resolution.includes('720p')) return '$1.28/Run'
|
||||
if (resolution.includes('540p')) return '$0.72/Run'
|
||||
}
|
||||
} else if (model.includes('ray-1-6')) {
|
||||
return '$0.50/Run'
|
||||
return '$0.35/Run'
|
||||
}
|
||||
|
||||
return '$0.79/Run'
|
||||
return '$0.55/Run'
|
||||
}
|
||||
},
|
||||
MinimaxImageToVideoNode: {
|
||||
@@ -1323,18 +1323,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !aspectRatioWidget) {
|
||||
return '$0.0064-0.026/Run (varies with model & aspect ratio)'
|
||||
return '$0.0045-0.0182/Run (varies with model & aspect ratio)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
if (model.includes('photon-flash-1')) {
|
||||
return '$0.0027/Run'
|
||||
return '$0.0019/Run'
|
||||
} else if (model.includes('photon-1')) {
|
||||
return '$0.0104/Run'
|
||||
return '$0.0073/Run'
|
||||
}
|
||||
|
||||
return '$0.0246/Run'
|
||||
return '$0.0172/Run'
|
||||
}
|
||||
},
|
||||
LumaImageModifyNode: {
|
||||
@@ -1344,18 +1344,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget) {
|
||||
return '$0.0027-0.0104/Run (varies with model)'
|
||||
return '$0.0019-0.0073/Run (varies with model)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
if (model.includes('photon-flash-1')) {
|
||||
return '$0.0027/Run'
|
||||
return '$0.0019/Run'
|
||||
} else if (model.includes('photon-1')) {
|
||||
return '$0.0104/Run'
|
||||
return '$0.0073/Run'
|
||||
}
|
||||
|
||||
return '$0.0246/Run'
|
||||
return '$0.0172/Run'
|
||||
}
|
||||
},
|
||||
MoonvalleyTxt2VideoNode: {
|
||||
@@ -1417,7 +1417,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
// Runway nodes - using actual node names from ComfyUI
|
||||
RunwayTextToImageNode: {
|
||||
displayPrice: '$0.11/Run'
|
||||
displayPrice: '$0.08/Run'
|
||||
},
|
||||
RunwayImageToVideoNodeGen3a: {
|
||||
displayPrice: calculateRunwayDurationPrice
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Composable for building docs.comfy.org URLs with automatic locale and platform detection
|
||||
@@ -23,7 +23,7 @@ import { useI18n } from 'vue-i18n'
|
||||
* ```
|
||||
*/
|
||||
export function useExternalLink() {
|
||||
const { locale } = useI18n()
|
||||
const locale = computed(() => String(i18n.global.locale.value))
|
||||
|
||||
const isChinese = computed(() => {
|
||||
return locale.value === 'zh' || locale.value === 'zh-TW'
|
||||
|
||||
34
src/config/stripePricingTableConfig.ts
Normal file
34
src/config/stripePricingTableConfig.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
export const STRIPE_PRICING_TABLE_SCRIPT_SRC =
|
||||
'https://js.stripe.com/v3/pricing-table.js'
|
||||
|
||||
interface StripePricingTableConfig {
|
||||
publishableKey: string
|
||||
pricingTableId: string
|
||||
}
|
||||
|
||||
function getEnvValue(
|
||||
key: 'VITE_STRIPE_PUBLISHABLE_KEY' | 'VITE_STRIPE_PRICING_TABLE_ID'
|
||||
) {
|
||||
return import.meta.env[key]
|
||||
}
|
||||
|
||||
export function getStripePricingTableConfig(): StripePricingTableConfig {
|
||||
const publishableKey =
|
||||
remoteConfig.value.stripe_publishable_key ||
|
||||
window.__CONFIG__?.stripe_publishable_key ||
|
||||
getEnvValue('VITE_STRIPE_PUBLISHABLE_KEY') ||
|
||||
''
|
||||
|
||||
const pricingTableId =
|
||||
remoteConfig.value.stripe_pricing_table_id ||
|
||||
window.__CONFIG__?.stripe_pricing_table_id ||
|
||||
getEnvValue('VITE_STRIPE_PRICING_TABLE_ID') ||
|
||||
''
|
||||
|
||||
return {
|
||||
publishableKey,
|
||||
pricingTableId
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,15 @@ app.registerExtension({
|
||||
static collapsable: boolean
|
||||
static title_mode: number
|
||||
|
||||
override color = LGraphCanvas.node_colors.yellow.color
|
||||
override bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
|
||||
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
|
||||
override isVirtualNode: boolean
|
||||
|
||||
constructor(title: string) {
|
||||
super(title)
|
||||
|
||||
this.color = LGraphCanvas.node_colors.yellow.color
|
||||
this.bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
|
||||
|
||||
if (!this.properties) {
|
||||
this.properties = { text: '' }
|
||||
}
|
||||
@@ -53,12 +55,14 @@ app.registerExtension({
|
||||
class MarkdownNoteNode extends LGraphNode {
|
||||
static override title = 'Markdown Note'
|
||||
|
||||
override color = LGraphCanvas.node_colors.yellow.color
|
||||
override bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
|
||||
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
|
||||
|
||||
constructor(title: string) {
|
||||
super(title)
|
||||
|
||||
this.color = LGraphCanvas.node_colors.yellow.color
|
||||
this.bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
|
||||
|
||||
if (!this.properties) {
|
||||
this.properties = { text: '' }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -200,6 +200,15 @@
|
||||
"copy": "Copy",
|
||||
"copyJobId": "Copy Job ID",
|
||||
"copied": "Copied",
|
||||
"relativeTime": {
|
||||
"now": "now",
|
||||
"yearsAgo": "{count}y ago",
|
||||
"monthsAgo": "{count}mo ago",
|
||||
"weeksAgo": "{count}w ago",
|
||||
"daysAgo": "{count}d ago",
|
||||
"hoursAgo": "{count}h ago",
|
||||
"minutesAgo": "{count}min ago"
|
||||
},
|
||||
"jobIdCopied": "Job ID copied to clipboard",
|
||||
"failedToCopyJobId": "Failed to copy job ID",
|
||||
"imageUrl": "Image URL",
|
||||
@@ -466,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": {
|
||||
@@ -747,9 +757,10 @@
|
||||
}
|
||||
},
|
||||
"helpCenter": {
|
||||
"feedback": "Give Feedback",
|
||||
"docs": "Docs",
|
||||
"github": "Github",
|
||||
"helpFeedback": "Help & Feedback",
|
||||
"help": "Help & Support",
|
||||
"managerExtension": "Manager Extension",
|
||||
"more": "More...",
|
||||
"whatsNew": "What's New?",
|
||||
@@ -763,10 +774,11 @@
|
||||
"reinstall": "Re-Install"
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "New Version Available!",
|
||||
"whatsNew": "What's New?",
|
||||
"newVersionAvailable": "New update is out!",
|
||||
"whatsNew": "See what's new",
|
||||
"skip": "Skip",
|
||||
"update": "Update"
|
||||
"update": "Update",
|
||||
"description": "Check out the latest improvements and features in this update."
|
||||
},
|
||||
"menu": {
|
||||
"hideMenu": "Hide Menu",
|
||||
@@ -1042,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",
|
||||
@@ -1843,6 +1867,8 @@
|
||||
"videosEstimate": "~{count} videos*",
|
||||
"templateNote": "*Generated with Wan Fun Control template",
|
||||
"buy": "Buy",
|
||||
"purchaseSuccess": "Purchase Successful",
|
||||
"purchaseSuccessDetail": "Successfully purchased {credits} credits for {amount}",
|
||||
"purchaseError": "Purchase Failed",
|
||||
"purchaseErrorDetail": "Failed to purchase credits: {error}",
|
||||
"unknownError": "An unknown error occurred"
|
||||
@@ -1868,8 +1894,8 @@
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"beta": "BETA",
|
||||
"perMonth": "/ month",
|
||||
"usdPerMonth": "USD / month",
|
||||
"renewsDate": "Renews {date}",
|
||||
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
@@ -1882,7 +1908,7 @@
|
||||
"monthlyBonusDescription": "Monthly credit bonus",
|
||||
"prepaidDescription": "Pre-paid credits",
|
||||
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
|
||||
"creditsRemainingThisMonth": "Credits remaining this month",
|
||||
"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",
|
||||
@@ -1898,30 +1924,25 @@
|
||||
},
|
||||
"tiers": {
|
||||
"founder": {
|
||||
"name": "Founder's Edition",
|
||||
"name": "Founder's Edition Standard",
|
||||
"price": "20.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "5,460",
|
||||
"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"
|
||||
"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",
|
||||
"name": "Standard",
|
||||
"price": "20.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "4,200",
|
||||
"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",
|
||||
"videoEstimate": "120"
|
||||
"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": {
|
||||
@@ -1934,22 +1955,19 @@
|
||||
"maxDurationLabel": "max duration of each workflow run",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimate": "288"
|
||||
"customLoRAsLabel": "Import your own LoRAs"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"price": "100.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "21,100",
|
||||
"monthlyCreditsLabel": "monthly credits",
|
||||
"maxDuration": "1 hr",
|
||||
"maxDurationLabel": "max duration of each workflow run",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimate": "815"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1972,31 +1990,7 @@
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "view enterprise",
|
||||
"partnerNodesCredits": "Partner nodes pricing",
|
||||
"mostPopular": "Most popular",
|
||||
"currentPlan": "Current Plan",
|
||||
"subscribeTo": "Subscribe to {plan}",
|
||||
"monthlyCreditsLabel": "Monthly credits",
|
||||
"maxDurationLabel": "Max duration of each workflow run",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan Fun Control template",
|
||||
"videoEstimateHelp": "What is this?",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan Fun Control template for 5-second videos.",
|
||||
"videoEstimateTryTemplate": "Try the Wan Fun Control template →",
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
"credits": {
|
||||
"standard": "4,200",
|
||||
"creator": "7,400",
|
||||
"pro": "21,100"
|
||||
},
|
||||
"maxDuration": {
|
||||
"standard": "30 min",
|
||||
"creator": "30 min",
|
||||
"pro": "1 hr"
|
||||
}
|
||||
"partnerNodesCredits": "Partner nodes pricing"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "My Account Settings",
|
||||
@@ -2040,6 +2034,7 @@
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "Learn more",
|
||||
"later": "Later",
|
||||
"noReleaseNotes": "No release notes available."
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
@@ -2195,71 +2190,76 @@
|
||||
"cloudSurvey_steps_industry": "What's your primary industry?",
|
||||
"cloudSurvey_steps_making": "What do you plan on making?",
|
||||
"assetBrowser": {
|
||||
"assets": "Assets",
|
||||
"allCategory": "All {category}",
|
||||
"allModels": "All Models",
|
||||
"assetCollection": "Asset collection",
|
||||
"checkpoints": "Checkpoints",
|
||||
"assets": "Assets",
|
||||
"baseModels": "Base models",
|
||||
"browseAssets": "Browse Assets",
|
||||
"noAssetsFound": "No assets found",
|
||||
"tryAdjustingFilters": "Try adjusting your search or filters",
|
||||
"loadingModels": "Loading {type}...",
|
||||
"connectionError": "Please check your connection and try again",
|
||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"uploadModel": "Import",
|
||||
"uploadModelFromCivitai": "Import a model from Civitai",
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com/models\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models</a> are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
|
||||
"checkpoints": "Checkpoints",
|
||||
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor</a>",
|
||||
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295</a>",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "e.g., models, checkpoint",
|
||||
"tagsHelp": "Separate tags with commas",
|
||||
"upload": "Import",
|
||||
"uploadingModel": "Importing model...",
|
||||
"uploadSuccess": "Model imported successfully!",
|
||||
"uploadFailed": "Import failed",
|
||||
"uploadModelHelpVideo": "Upload Model Help Video",
|
||||
"uploadModelHowDoIFindThis": "How do I find this?",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"modelTypeSelectorLabel": "What type of model is this?",
|
||||
"modelTypeSelectorPlaceholder": "Select model type",
|
||||
"selectModelType": "Select model type",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"modelUploaded": "Model successfully imported.",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
|
||||
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
|
||||
"allModels": "All Models",
|
||||
"allCategory": "All {category}",
|
||||
"unknown": "Unknown",
|
||||
"fileFormats": "File formats",
|
||||
"baseModels": "Base models",
|
||||
"filterBy": "Filter by",
|
||||
"sortBy": "Sort by",
|
||||
"sortAZ": "A-Z",
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular",
|
||||
"selectFrameworks": "Select Frameworks",
|
||||
"selectProjects": "Select Projects",
|
||||
"sortingType": "Sorting Type",
|
||||
"connectionError": "Please check your connection and try again",
|
||||
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
|
||||
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
|
||||
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
|
||||
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
|
||||
"errorModelTypeNotSupported": "This model type is not supported",
|
||||
"errorUnknown": "An unexpected error occurred",
|
||||
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
|
||||
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
|
||||
"errorUploadFailed": "Failed to import asset. Please try again.",
|
||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||
"fileFormats": "File formats",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
"filterBy": "Filter by",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"jobId": "Job ID",
|
||||
"loadingModels": "Loading {type}...",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
"modelTypeSelectorLabel": "What type of model is this?",
|
||||
"modelTypeSelectorPlaceholder": "Select model type",
|
||||
"modelUploaded": "Model imported! 🎉",
|
||||
"noAssetsFound": "No assets found",
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"ownership": "Ownership",
|
||||
"ownershipAll": "All",
|
||||
"ownershipMyModels": "My models",
|
||||
"ownershipPublicModels": "Public models",
|
||||
"selectFrameworks": "Select Frameworks",
|
||||
"selectModelType": "Select model type",
|
||||
"selectProjects": "Select Projects",
|
||||
"sortAZ": "A-Z",
|
||||
"sortBy": "Sort by",
|
||||
"sortPopular": "Popular",
|
||||
"sortRecent": "Recent",
|
||||
"sortZA": "Z-A",
|
||||
"sortingType": "Sorting Type",
|
||||
"tags": "Tags",
|
||||
"tagsHelp": "Separate tags with commas",
|
||||
"tagsPlaceholder": "e.g., models, checkpoint",
|
||||
"tryAdjustingFilters": "Try adjusting your search or filters",
|
||||
"unknown": "Unknown",
|
||||
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
|
||||
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
|
||||
"upload": "Import",
|
||||
"uploadFailed": "Import failed",
|
||||
"uploadingModel": "Importing model...",
|
||||
"uploadModel": "Import",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com</a> are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"uploadModelFromCivitai": "Import a model from Civitai",
|
||||
"uploadModelHelpVideo": "Upload Model Help Video",
|
||||
"uploadModelHowDoIFindThis": "How do I find this?",
|
||||
"uploadSuccess": "Model imported successfully!",
|
||||
"ariaLabel": {
|
||||
"assetCard": "{name} - {type} asset",
|
||||
"loadingAsset": "Loading asset"
|
||||
@@ -2384,6 +2384,12 @@
|
||||
"inputs": "INPUTS",
|
||||
"inputsNone": "NO INPUTS",
|
||||
"inputsNoneTooltip": "Node has no inputs",
|
||||
"nodeState": "Node state"
|
||||
"properties": "Properties",
|
||||
"nodeState": "Node state",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"help": {
|
||||
"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": "更多...",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<template #contentFilter>
|
||||
<AssetFilterBar
|
||||
:assets="categoryFilteredAssets"
|
||||
:all-assets="fetchedAssets"
|
||||
@filter-change="updateFilters"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
data-component-id="asset-filter-base-models"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
|
||||
<SingleSelect
|
||||
v-if="hasMutableAssets"
|
||||
v-model="ownership"
|
||||
:label="$t('assetBrowser.ownership')"
|
||||
:options="ownershipOptions"
|
||||
class="min-w-42"
|
||||
data-component-id="asset-filter-ownership"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center" data-component-id="asset-filter-bar-right">
|
||||
@@ -46,21 +56,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import { t } from '@/i18n'
|
||||
import type { OwnershipOption } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
export interface FilterState {
|
||||
fileFormats: string[]
|
||||
baseModels: string[]
|
||||
sortBy: string
|
||||
}
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
|
||||
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
|
||||
@@ -71,17 +76,37 @@ type SortOption = (typeof SORT_OPTIONS)[number]['value']
|
||||
|
||||
const sortOptions = [...SORT_OPTIONS]
|
||||
|
||||
const { assets = [] } = defineProps<{
|
||||
const ownershipOptions = [
|
||||
{ name: t('assetBrowser.ownershipAll'), value: 'all' },
|
||||
{ name: t('assetBrowser.ownershipMyModels'), value: 'my-models' },
|
||||
{ name: t('assetBrowser.ownershipPublicModels'), value: 'public-models' }
|
||||
]
|
||||
|
||||
export interface FilterState {
|
||||
fileFormats: string[]
|
||||
baseModels: string[]
|
||||
sortBy: string
|
||||
ownership: OwnershipOption
|
||||
}
|
||||
|
||||
const { assets = [], allAssets = [] } = defineProps<{
|
||||
assets?: AssetItem[]
|
||||
allAssets?: AssetItem[]
|
||||
}>()
|
||||
|
||||
const fileFormats = ref<SelectOption[]>([])
|
||||
const baseModels = ref<SelectOption[]>([])
|
||||
const sortBy = ref<SortOption>('recent')
|
||||
const ownership = ref<OwnershipOption>('all')
|
||||
|
||||
const { availableFileFormats, availableBaseModels } =
|
||||
useAssetFilterOptions(assets)
|
||||
|
||||
const hasMutableAssets = computed(() => {
|
||||
const assetsToCheck = allAssets.length ? allAssets : assets
|
||||
return assetsToCheck.some((asset) => asset.is_immutable === false)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
filterChange: [filters: FilterState]
|
||||
}>()
|
||||
@@ -90,7 +115,8 @@ function handleFilterChange() {
|
||||
emit('filterChange', {
|
||||
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
||||
sortBy: sortBy.value
|
||||
sortBy: sortBy.value,
|
||||
ownership: ownership.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<div class="flex gap-3">
|
||||
<SearchBox
|
||||
:model-value="searchQuery"
|
||||
:placeholder="$t('sideToolbar.searchAssets')"
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getAssetDescription
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
export type OwnershipOption = 'all' | 'my-models' | 'public-models'
|
||||
|
||||
function filterByCategory(category: string) {
|
||||
return (asset: AssetItem) => {
|
||||
return category === 'all' || asset.tags.includes(category)
|
||||
@@ -35,6 +37,15 @@ function filterByBaseModels(models: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function filterByOwnership(ownership: OwnershipOption) {
|
||||
return (asset: AssetItem) => {
|
||||
if (ownership === 'all') return true
|
||||
if (ownership === 'my-models') return asset.is_immutable === false
|
||||
if (ownership === 'public-models') return asset.is_immutable === true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
type AssetBadge = {
|
||||
label: string
|
||||
type: 'type' | 'base' | 'size'
|
||||
@@ -65,7 +76,8 @@ export function useAssetBrowser(
|
||||
const filters = ref<FilterState>({
|
||||
sortBy: 'recent',
|
||||
fileFormats: [],
|
||||
baseModels: []
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
|
||||
// Transform API asset to display asset
|
||||
@@ -176,6 +188,7 @@ export function useAssetBrowser(
|
||||
const filtered = searchFiltered.value
|
||||
.filter(filterByFileFormats(filters.value.fileFormats))
|
||||
.filter(filterByBaseModels(filters.value.baseModels))
|
||||
.filter(filterByOwnership(filters.value.ownership))
|
||||
|
||||
const sortedAssets = [...filtered]
|
||||
sortedAssets.sort((a, b) => {
|
||||
|
||||
@@ -146,9 +146,15 @@ export function createAssetWithoutUserMetadata() {
|
||||
return asset
|
||||
}
|
||||
|
||||
export function createAssetWithSpecificExtension(extension: string) {
|
||||
export function createAssetWithSpecificExtension(
|
||||
extension: string,
|
||||
isImmutable?: boolean
|
||||
) {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.name = `test-model.${extension}`
|
||||
if (isImmutable !== undefined) {
|
||||
asset.is_immutable = isImmutable
|
||||
}
|
||||
return asset
|
||||
}
|
||||
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-stretch gap-6">
|
||||
<div
|
||||
v-for="tier in tiers"
|
||||
:key="tier.id"
|
||||
class="flex-1 flex flex-col rounded-2xl border border-interface-stroke bg-interface-panel-surface shadow-[0_0_12px_rgba(0,0,0,0.1)]"
|
||||
>
|
||||
<div class="flex flex-col gap-6 p-8">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span
|
||||
class="font-inter text-base font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
{{ tier.name }}
|
||||
</span>
|
||||
<div
|
||||
v-if="tier.isPopular"
|
||||
class="rounded-full bg-background px-1 text-xs font-semibold uppercase tracking-wide text-foreground h-[13px] leading-[13px]"
|
||||
>
|
||||
{{ t('subscription.mostPopular') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-baseline gap-2">
|
||||
<span
|
||||
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
|
||||
>
|
||||
${{ tier.price }}
|
||||
</span>
|
||||
<span
|
||||
class="font-inter text-base font-normal leading-normal text-base-foreground"
|
||||
>
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 px-8 pb-0 flex-1">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span
|
||||
class="font-inter text-sm font-normal leading-normal text-muted-foreground"
|
||||
>
|
||||
{{ t('subscription.monthlyCreditsLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-sm" />
|
||||
<span
|
||||
class="font-inter text-sm font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
{{ tier.credits }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.maxDurationLabel') }}
|
||||
</span>
|
||||
<span
|
||||
class="font-inter text-sm font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
{{ tier.maxDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.gpuLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-success-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.addCreditsLabel') }}
|
||||
</span>
|
||||
<i class="pi pi-check text-xs text-success-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.customLoRAsLabel') }}
|
||||
</span>
|
||||
<i
|
||||
v-if="tier.customLoRAs"
|
||||
class="pi pi-check text-xs text-success-foreground"
|
||||
/>
|
||||
<i v-else class="pi pi-times text-xs text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-start justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-normal text-muted-foreground">
|
||||
{{ t('subscription.videoEstimateLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 opacity-50">
|
||||
<i
|
||||
class="pi pi-question-circle text-xs text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-normal text-muted-foreground cursor-pointer hover:text-base-foreground"
|
||||
@click="togglePopover"
|
||||
>
|
||||
{{ t('subscription.videoEstimateHelp') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="font-inter text-sm font-bold leading-normal text-base-foreground"
|
||||
>
|
||||
{{ tier.videoEstimate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col p-8">
|
||||
<Button
|
||||
:label="getButtonLabel(tier)"
|
||||
:severity="getButtonSeverity(tier)"
|
||||
:disabled="isLoading || isCurrentPlan(tier.key)"
|
||||
:loading="loadingTier === tier.key"
|
||||
class="h-10 w-full"
|
||||
:pt="{
|
||||
label: {
|
||||
class:
|
||||
'font-inter text-sm font-bold leading-normal text-primary-foreground'
|
||||
}
|
||||
}"
|
||||
@click="() => handleSubscribe(tier.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Estimate Help Popover -->
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 underline"
|
||||
>
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
type TierKey = 'standard' | 'creator' | 'pro'
|
||||
|
||||
interface PricingTierConfig {
|
||||
id: SubscriptionTier
|
||||
key: TierKey
|
||||
name: string
|
||||
price: string
|
||||
credits: string
|
||||
maxDuration: string
|
||||
customLoRAs: boolean
|
||||
videoEstimate: string
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
FOUNDERS_EDITION: 'standard'
|
||||
}
|
||||
|
||||
const tiers: PricingTierConfig[] = [
|
||||
{
|
||||
id: 'STANDARD',
|
||||
key: 'standard',
|
||||
name: t('subscription.tiers.standard.name'),
|
||||
price: t('subscription.tiers.standard.price'),
|
||||
credits: t('subscription.credits.standard'),
|
||||
maxDuration: t('subscription.maxDuration.standard'),
|
||||
customLoRAs: false,
|
||||
videoEstimate: t('subscription.tiers.standard.benefits.videoEstimate'),
|
||||
isPopular: false
|
||||
},
|
||||
{
|
||||
id: 'CREATOR',
|
||||
key: 'creator',
|
||||
name: t('subscription.tiers.creator.name'),
|
||||
price: t('subscription.tiers.creator.price'),
|
||||
credits: t('subscription.credits.creator'),
|
||||
maxDuration: t('subscription.maxDuration.creator'),
|
||||
customLoRAs: true,
|
||||
videoEstimate: t('subscription.tiers.creator.benefits.videoEstimate'),
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
id: 'PRO',
|
||||
key: 'pro',
|
||||
name: t('subscription.tiers.pro.name'),
|
||||
price: t('subscription.tiers.pro.price'),
|
||||
credits: t('subscription.credits.pro'),
|
||||
maxDuration: t('subscription.maxDuration.pro'),
|
||||
customLoRAs: true,
|
||||
videoEstimate: t('subscription.tiers.pro.benefits.videoEstimate'),
|
||||
isPopular: false
|
||||
}
|
||||
]
|
||||
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const { isActiveSubscription, subscriptionTier } = useSubscription()
|
||||
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const loadingTier = ref<TierKey | null>(null)
|
||||
const popover = ref()
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
|
||||
)
|
||||
|
||||
const isCurrentPlan = (tierKey: TierKey): boolean =>
|
||||
currentTierKey.value === tierKey
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
|
||||
const getButtonLabel = (tier: PricingTierConfig): string => {
|
||||
if (isCurrentPlan(tier.key)) return t('subscription.currentPlan')
|
||||
if (!isActiveSubscription.value)
|
||||
return t('subscription.subscribeTo', { plan: tier.name })
|
||||
return t('subscription.changeTo', { plan: tier.name })
|
||||
}
|
||||
|
||||
const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>
|
||||
isCurrentPlan(tier.key)
|
||||
? 'secondary'
|
||||
: tier.key === 'creator'
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
|
||||
const initiateCheckout = async (tierKey: TierKey) => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${tierKey}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to initiate checkout'
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.message || errorMessage
|
||||
} catch {
|
||||
// If JSON parsing fails, try to get text response or use HTTP status
|
||||
try {
|
||||
const errorText = await response.text()
|
||||
errorMessage =
|
||||
errorText || `HTTP ${response.status} ${response.statusText}`
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status} ${response.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
throw new FirebaseAuthStoreError(
|
||||
t('toastMessages.failedToInitiateSubscription', {
|
||||
error: errorMessage
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
const handleSubscribe = wrapWithErrorHandlingAsync(async (tierKey: TierKey) => {
|
||||
if (!isCloud || isLoading.value || isCurrentPlan(tierKey)) return
|
||||
|
||||
isLoading.value = true
|
||||
loadingTier.value = tierKey
|
||||
|
||||
try {
|
||||
if (isActiveSubscription.value) {
|
||||
await accessBillingPortal()
|
||||
} else {
|
||||
const response = await initiateCheckout(tierKey)
|
||||
if (response.checkout_url) {
|
||||
window.open(response.checkout_url, '_blank')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
loadingTier.value = null
|
||||
}
|
||||
}, reportError)
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div
|
||||
ref="tableContainer"
|
||||
class="relative w-full rounded-[20px] border border-interface-stroke bg-interface-panel-background"
|
||||
>
|
||||
<div
|
||||
v-if="!hasValidConfig"
|
||||
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
|
||||
data-testid="stripe-table-missing-config"
|
||||
>
|
||||
{{ $t('subscription.pricingTable.missingConfig') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loadError"
|
||||
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
|
||||
data-testid="stripe-table-error"
|
||||
>
|
||||
{{ $t('subscription.pricingTable.loadError') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!isReady"
|
||||
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
|
||||
data-testid="stripe-table-loading"
|
||||
>
|
||||
{{ $t('subscription.pricingTable.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { getStripePricingTableConfig } from '@/config/stripePricingTableConfig'
|
||||
import { useStripePricingTableLoader } from '@/platform/cloud/subscription/composables/useStripePricingTableLoader'
|
||||
|
||||
const props = defineProps<{
|
||||
pricingTableId?: string
|
||||
publishableKey?: string
|
||||
}>()
|
||||
|
||||
const tableContainer = ref<HTMLDivElement | null>(null)
|
||||
const isReady = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
const lastRenderedKey = ref('')
|
||||
const stripeElement = ref<HTMLElement | null>(null)
|
||||
|
||||
const resolvedConfig = computed(() => {
|
||||
const fallback = getStripePricingTableConfig()
|
||||
|
||||
return {
|
||||
publishableKey: props.publishableKey || fallback.publishableKey,
|
||||
pricingTableId: props.pricingTableId || fallback.pricingTableId
|
||||
}
|
||||
})
|
||||
|
||||
const hasValidConfig = computed(() => {
|
||||
const { publishableKey, pricingTableId } = resolvedConfig.value
|
||||
return Boolean(publishableKey && pricingTableId)
|
||||
})
|
||||
|
||||
const { loadScript } = useStripePricingTableLoader()
|
||||
|
||||
const renderPricingTable = async () => {
|
||||
if (!tableContainer.value) return
|
||||
|
||||
const { publishableKey, pricingTableId } = resolvedConfig.value
|
||||
if (!publishableKey || !pricingTableId) {
|
||||
return
|
||||
}
|
||||
|
||||
const renderKey = `${publishableKey}:${pricingTableId}`
|
||||
if (renderKey === lastRenderedKey.value && isReady.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await loadScript()
|
||||
loadError.value = null
|
||||
if (!tableContainer.value) {
|
||||
return
|
||||
}
|
||||
if (stripeElement.value) {
|
||||
stripeElement.value.remove()
|
||||
stripeElement.value = null
|
||||
}
|
||||
const stripeTable = document.createElement('stripe-pricing-table')
|
||||
stripeTable.setAttribute('publishable-key', publishableKey)
|
||||
stripeTable.setAttribute('pricing-table-id', pricingTableId)
|
||||
stripeTable.style.display = 'block'
|
||||
stripeTable.style.width = '100%'
|
||||
stripeTable.style.minHeight = '420px'
|
||||
tableContainer.value.appendChild(stripeTable)
|
||||
stripeElement.value = stripeTable
|
||||
lastRenderedKey.value = renderKey
|
||||
isReady.value = true
|
||||
} catch (error) {
|
||||
console.error('[StripePricingTable] Failed to load pricing table', error)
|
||||
loadError.value = (error as Error).message
|
||||
isReady.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[resolvedConfig, () => tableContainer.value],
|
||||
() => {
|
||||
if (!hasValidConfig.value) return
|
||||
if (!tableContainer.value) return
|
||||
void renderPricingTable()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stripeElement.value?.remove()
|
||||
stripeElement.value = null
|
||||
})
|
||||
</script>
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
{{ tierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
@@ -138,6 +138,19 @@
|
||||
>
|
||||
{{ $t('subscription.creditsRemainingThisMonth') }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="refreshTooltip"
|
||||
icon="pi pi-question-circle"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -340,23 +353,8 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
||||
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 type { components } from '@/types/comfyRegistryTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
|
||||
/** Maps API subscription tier values to i18n translation keys */
|
||||
const TIER_TO_I18N_KEY = {
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
FOUNDERS_EDITION: 'founder'
|
||||
} as const satisfies Record<SubscriptionTier, string>
|
||||
|
||||
type TierKey = (typeof TIER_TO_I18N_KEY)[SubscriptionTier]
|
||||
|
||||
const DEFAULT_TIER_KEY: TierKey = 'standard'
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -365,19 +363,14 @@ const {
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
handleInvoiceHistory
|
||||
} = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_I18N_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
const tierPrice = computed(() => t(`subscription.tiers.${tierKey.value}.price`))
|
||||
// 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'
|
||||
@@ -389,49 +382,38 @@ interface Benefit {
|
||||
value?: string
|
||||
}
|
||||
|
||||
const BENEFITS_BY_TIER: Record<
|
||||
TierKey,
|
||||
ReadonlyArray<Omit<Benefit, 'label' | 'value'>>
|
||||
> = {
|
||||
standard: [
|
||||
{ key: 'monthlyCredits', type: 'metric' },
|
||||
{ key: 'maxDuration', type: 'metric' },
|
||||
{ key: 'gpu', type: 'feature' },
|
||||
{ key: 'addCredits', type: 'feature' }
|
||||
],
|
||||
creator: [
|
||||
{ key: 'monthlyCredits', type: 'metric' },
|
||||
{ key: 'maxDuration', type: 'metric' },
|
||||
{ key: 'gpu', type: 'feature' },
|
||||
{ key: 'addCredits', type: 'feature' },
|
||||
{ key: 'customLoRAs', type: 'feature' }
|
||||
],
|
||||
pro: [
|
||||
{ key: 'monthlyCredits', type: 'metric' },
|
||||
{ key: 'maxDuration', type: 'metric' },
|
||||
{ key: 'gpu', type: 'feature' },
|
||||
{ key: 'addCredits', type: 'feature' },
|
||||
{ key: 'customLoRAs', type: 'feature' }
|
||||
],
|
||||
founder: [
|
||||
{ key: 'monthlyCredits', type: 'metric' },
|
||||
{ key: 'maxDuration', type: 'metric' },
|
||||
{ key: 'gpu', type: 'feature' },
|
||||
{ key: 'addCredits', type: 'feature' }
|
||||
]
|
||||
}
|
||||
|
||||
const tierBenefits = computed(() => {
|
||||
const key = tierKey.value
|
||||
const benefitConfig = BENEFITS_BY_TIER[key]
|
||||
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 benefitConfig.map((config) => ({
|
||||
...config,
|
||||
...(config.type === 'metric' && {
|
||||
value: t(`subscription.tiers.${key}.benefits.${config.key}`)
|
||||
}),
|
||||
label: t(`subscription.tiers.${key}.benefits.${config.key}Label`)
|
||||
}))
|
||||
return baseBenefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
@@ -439,6 +421,7 @@ const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
|
||||
const {
|
||||
isLoadingSupport,
|
||||
refreshTooltip,
|
||||
handleAddApiCredits,
|
||||
handleMessageSupport,
|
||||
handleRefresh,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="showCustomPricingTable"
|
||||
v-if="showStripePricingTable"
|
||||
class="flex flex-col gap-6 rounded-[24px] border border-interface-stroke bg-[var(--p-dialog-background)] p-4 shadow-[0_25px_80px_rgba(5,6,12,0.45)] md:p-6"
|
||||
>
|
||||
<div
|
||||
@@ -32,7 +32,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PricingTable class="flex-1" />
|
||||
<StripePricingTable class="flex-1" />
|
||||
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center">
|
||||
@@ -46,7 +46,7 @@
|
||||
severity="secondary"
|
||||
icon="pi pi-comments"
|
||||
icon-pos="right"
|
||||
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
|
||||
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
|
||||
@click="handleContactUs"
|
||||
/>
|
||||
<span class="text-sm text-text-secondary">{{ $t('g.or') }}</span>
|
||||
@@ -56,7 +56,7 @@
|
||||
severity="secondary"
|
||||
icon="pi pi-external-link"
|
||||
icon-pos="right"
|
||||
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
|
||||
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
|
||||
@click="handleViewEnterprise"
|
||||
/>
|
||||
</div>
|
||||
@@ -138,8 +138,8 @@ import Button from 'primevue/button'
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
|
||||
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
|
||||
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'
|
||||
@@ -155,30 +155,27 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { fetchStatus, isActiveSubscription } = useSubscription()
|
||||
|
||||
// Legacy price for non-tier flow with locale-aware formatting
|
||||
const formattedMonthlyPrice = new Intl.NumberFormat(
|
||||
navigator.language || 'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
).format(MONTHLY_SUBSCRIPTION_PRICE)
|
||||
const { formattedMonthlyPrice, fetchStatus, isActiveSubscription } =
|
||||
useSubscription()
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
const subscriptionTiersEnabled = featureFlag(
|
||||
'subscription_tiers_enabled',
|
||||
false
|
||||
)
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
// Always show custom pricing table for cloud subscriptions
|
||||
const showCustomPricingTable = computed(
|
||||
() => isCloud && window.__CONFIG__?.subscription_required
|
||||
const showStripePricingTable = computed(
|
||||
() =>
|
||||
subscriptionTiersEnabled.value &&
|
||||
isCloud &&
|
||||
window.__CONFIG__?.subscription_required
|
||||
)
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const MAX_POLL_ATTEMPTS = 3
|
||||
const MAX_POLL_DURATION_MS = 5 * 60 * 1000
|
||||
let pollInterval: number | null = null
|
||||
let pollAttempts = 0
|
||||
let pollStartTime = 0
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
@@ -189,44 +186,35 @@ const stopPolling = () => {
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollAttempts = 0
|
||||
pollStartTime = Date.now()
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await fetchStatus()
|
||||
pollAttempts++
|
||||
|
||||
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscriptionDialog] Failed to poll subscription status',
|
||||
error
|
||||
)
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
pollInterval = window.setInterval(() => {
|
||||
if (Date.now() - pollStartTime > MAX_POLL_DURATION_MS) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
void poll()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
if (showCustomPricingTable.value) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
showCustomPricingTable,
|
||||
showStripePricingTable,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
startPolling()
|
||||
} else {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
@@ -236,7 +224,7 @@ watch(
|
||||
watch(
|
||||
() => isActiveSubscription.value,
|
||||
(isActive) => {
|
||||
if (isActive && showCustomPricingTable.value) {
|
||||
if (isActive && showStripePricingTable.value) {
|
||||
emit('close', true)
|
||||
}
|
||||
}
|
||||
@@ -271,7 +259,6 @@ const handleViewEnterprise = () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { STRIPE_PRICING_TABLE_SCRIPT_SRC } from '@/config/stripePricingTableConfig'
|
||||
|
||||
function useStripePricingTableLoaderInternal() {
|
||||
const isLoaded = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
let pendingPromise: Promise<void> | null = null
|
||||
|
||||
const resolveLoaded = () => {
|
||||
isLoaded.value = true
|
||||
isLoading.value = false
|
||||
pendingPromise = null
|
||||
}
|
||||
|
||||
const resolveError = (err: Error) => {
|
||||
error.value = err
|
||||
isLoading.value = false
|
||||
pendingPromise = null
|
||||
}
|
||||
|
||||
const loadScript = (): Promise<void> => {
|
||||
if (isLoaded.value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (pendingPromise) {
|
||||
return pendingPromise
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${STRIPE_PRICING_TABLE_SCRIPT_SRC}"]`
|
||||
)
|
||||
|
||||
if (existingScript) {
|
||||
isLoading.value = true
|
||||
|
||||
pendingPromise = new Promise<void>((resolve, reject) => {
|
||||
existingScript.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
existingScript.dataset.loaded = 'true'
|
||||
resolveLoaded()
|
||||
resolve()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
existingScript.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
const err = new Error('Stripe pricing table script failed to load')
|
||||
resolveError(err)
|
||||
reject(err)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
// Check if script already loaded after attaching listeners
|
||||
if (
|
||||
existingScript.dataset.loaded === 'true' ||
|
||||
(existingScript as any).readyState === 'complete' ||
|
||||
(existingScript as any).complete
|
||||
) {
|
||||
existingScript.dataset.loaded = 'true'
|
||||
resolveLoaded()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
return pendingPromise
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
pendingPromise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = STRIPE_PRICING_TABLE_SCRIPT_SRC
|
||||
script.async = true
|
||||
script.dataset.loaded = 'false'
|
||||
|
||||
script.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
script.dataset.loaded = 'true'
|
||||
resolveLoaded()
|
||||
resolve()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
const err = new Error('Stripe pricing table script failed to load')
|
||||
resolveError(err)
|
||||
reject(err)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return pendingPromise
|
||||
}
|
||||
|
||||
return {
|
||||
loadScript,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
export const useStripePricingTableLoader = createSharedComposable(
|
||||
useStripePricingTableLoaderInternal
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -13,24 +14,17 @@ import {
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||
|
||||
type CloudSubscriptionCheckoutResponse = {
|
||||
checkout_url: string
|
||||
}
|
||||
|
||||
export type CloudSubscriptionStatusResponse = NonNullable<
|
||||
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
type SubscriptionTier = components['schemas']['SubscriptionTier']
|
||||
|
||||
const TIER_TO_I18N_KEY: Record<SubscriptionTier, string> = {
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
FOUNDERS_EDITION: 'founder'
|
||||
export type CloudSubscriptionStatusResponse = {
|
||||
is_active: boolean
|
||||
subscription_id: string
|
||||
renewal_date: string | null
|
||||
end_date?: string | null
|
||||
}
|
||||
|
||||
function useSubscriptionInternal() {
|
||||
@@ -78,17 +72,10 @@ function useSubscriptionInternal() {
|
||||
})
|
||||
})
|
||||
|
||||
const subscriptionTier = computed(
|
||||
() => subscriptionStatus.value?.subscription_tier ?? null
|
||||
const formattedMonthlyPrice = computed(
|
||||
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
|
||||
)
|
||||
|
||||
const subscriptionTierName = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return ''
|
||||
const key = TIER_TO_I18N_KEY[tier] ?? 'standard'
|
||||
return t(`subscription.tiers.${key}.name`)
|
||||
})
|
||||
|
||||
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
|
||||
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(
|
||||
@@ -240,9 +227,7 @@ function useSubscriptionInternal() {
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
formattedMonthlyPrice,
|
||||
|
||||
// Actions
|
||||
subscribe,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
@@ -7,18 +8,30 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const MONTHLY_CREDIT_BONUS_USD = 10
|
||||
|
||||
/**
|
||||
* Composable for handling subscription panel actions and loading states
|
||||
*/
|
||||
export function useSubscriptionActions() {
|
||||
const { t } = useI18n()
|
||||
const dialogService = useDialogService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { fetchStatus } = useSubscription()
|
||||
const { fetchStatus, formattedRenewalDate } = useSubscription()
|
||||
|
||||
const isLoadingSupport = ref(false)
|
||||
|
||||
const refreshTooltip = computed(() => {
|
||||
const date =
|
||||
formattedRenewalDate.value || t('subscription.nextBillingCycle')
|
||||
return t('subscription.refreshesOn', {
|
||||
monthlyCreditBonusUsd: MONTHLY_CREDIT_BONUS_USD,
|
||||
date
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void handleRefresh()
|
||||
})
|
||||
@@ -59,6 +72,7 @@ export function useSubscriptionActions() {
|
||||
|
||||
return {
|
||||
isLoadingSupport,
|
||||
refreshTooltip,
|
||||
handleAddApiCredits,
|
||||
handleMessageSupport,
|
||||
handleRefresh,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -7,6 +10,14 @@ const DIALOG_KEY = 'subscription-required'
|
||||
export const useSubscriptionDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const showStripeDialog = computed(
|
||||
() =>
|
||||
flags.subscriptionTiersEnabled &&
|
||||
isCloud &&
|
||||
window.__CONFIG__?.subscription_required
|
||||
)
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
@@ -25,15 +36,19 @@ export const useSubscriptionDialog = () => {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(1200px, 95vw); max-height: 90vh;',
|
||||
pt: {
|
||||
root: {
|
||||
class: '!rounded-[32px] overflow-visible'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 bg-transparent'
|
||||
}
|
||||
}
|
||||
style: showStripeDialog.value
|
||||
? 'width: min(1100px, 90vw); max-height: 90vh;'
|
||||
: 'width: 700px;',
|
||||
pt: showStripeDialog.value
|
||||
? {
|
||||
root: {
|
||||
class: '!rounded-[32px] overflow-visible'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 bg-transparent'
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import { useReleaseStore } from '../common/releaseStore'
|
||||
import ReleaseNotificationToast from './ReleaseNotificationToast.vue'
|
||||
|
||||
// Mock release data with realistic CMS content
|
||||
const mockReleases: ReleaseNote[] = [
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: '1.2.3',
|
||||
attention: 'medium',
|
||||
published_at: '2024-01-15T10:00:00Z',
|
||||
content: `# ComfyUI 1.2.3 Release
|
||||
|
||||
**What's new**
|
||||
|
||||
New features and improvements for better workflow management.
|
||||
|
||||
- **Enhanced Node Editor**: Improved performance for large workflows with 100+ nodes
|
||||
- **Auto-save Feature**: Your work is now automatically saved every 30 seconds
|
||||
- **New Model Support**: Added support for FLUX.1-dev and FLUX.1-schnell models
|
||||
- **Bug Fixes**: Resolved memory leak issues in the backend processing`
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
project: 'comfyui',
|
||||
version: '1.2.4',
|
||||
attention: 'high',
|
||||
published_at: '2024-02-01T14:30:00Z',
|
||||
content: `# ComfyUI 1.2.4 Major Release
|
||||
|
||||
**What's new**
|
||||
|
||||
Revolutionary updates that change how you create with ComfyUI.
|
||||
|
||||
- **Real-time Collaboration**: Share and edit workflows with your team in real-time
|
||||
- **Advanced Upscaling**: New ESRGAN and Real-ESRGAN models built-in
|
||||
- **Custom Node Store**: Browse and install community nodes directly from the interface
|
||||
- **Performance Boost**: 40% faster generation times for SDXL models
|
||||
- **Dark Mode**: Beautiful new dark interface theme`
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
project: 'comfyui',
|
||||
version: '1.3.0',
|
||||
attention: 'high',
|
||||
published_at: '2024-03-10T09:15:00Z',
|
||||
content: `# ComfyUI 1.3.0 - The Biggest Update Yet
|
||||
|
||||
**What's new**
|
||||
|
||||
Introducing powerful new features that unlock creative possibilities.
|
||||
|
||||
- **AI-Powered Node Suggestions**: Get intelligent recommendations while building workflows
|
||||
- **Workflow Templates**: Start from professionally designed templates
|
||||
- **Advanced Queuing**: Batch process multiple generations with queue management
|
||||
- **Mobile Preview**: Preview your workflows on mobile devices
|
||||
- **API Improvements**: Enhanced REST API with better documentation
|
||||
- **Community Hub**: Share workflows and discover creations from other users`
|
||||
}
|
||||
]
|
||||
|
||||
interface StoryArgs {
|
||||
releaseData: ReleaseNote
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Platform/Updates/ReleaseNotificationToast',
|
||||
component: ReleaseNotificationToast,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
argTypes: {
|
||||
releaseData: {
|
||||
control: 'object',
|
||||
description: 'Release data with version and markdown content'
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(_, context) => {
|
||||
// Set up the store with mock data for this story
|
||||
const releaseStore = useReleaseStore()
|
||||
|
||||
// Patch store state directly for Storybook
|
||||
releaseStore.$patch({
|
||||
releases: [context.args.releaseData]
|
||||
})
|
||||
// Override shouldShowToast getter for Storybook
|
||||
Object.defineProperty(releaseStore, 'shouldShowToast', {
|
||||
get: () => true,
|
||||
configurable: true
|
||||
})
|
||||
// Override recentRelease getter for Storybook
|
||||
Object.defineProperty(releaseStore, 'recentRelease', {
|
||||
get: () => context.args.releaseData,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
// Mock the store methods to prevent errors
|
||||
releaseStore.handleSkipRelease = async () => {
|
||||
// Mock implementation for Storybook
|
||||
}
|
||||
releaseStore.handleShowChangelog = async () => {
|
||||
// Mock implementation for Storybook
|
||||
}
|
||||
|
||||
return {
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-base-background p-8">
|
||||
<story />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
releaseData: mockReleases[0]
|
||||
}
|
||||
}
|
||||
|
||||
export const MajorRelease: Story = {
|
||||
args: {
|
||||
releaseData: mockReleases[1]
|
||||
}
|
||||
}
|
||||
|
||||
export const ExtensiveFeatures: Story = {
|
||||
args: {
|
||||
releaseData: mockReleases[2]
|
||||
}
|
||||
}
|
||||
|
||||
export const LongContent: Story = {
|
||||
args: {
|
||||
releaseData: {
|
||||
id: 4,
|
||||
project: 'comfyui',
|
||||
version: '1.4.0',
|
||||
attention: 'high',
|
||||
published_at: '2024-04-05T11:00:00Z',
|
||||
content: `# ComfyUI 1.4.0 - Comprehensive Update
|
||||
|
||||
**What's new**
|
||||
|
||||
This is a comprehensive update with many new features and improvements. This release includes extensive changes across the entire platform.
|
||||
|
||||
- **Revolutionary Workflow Engine**: Complete rewrite of the workflow processing engine with 300% performance improvements
|
||||
- **Advanced Model Management**: Sophisticated model organization with tagging, favorites, and automatic duplicate detection
|
||||
- **Real-time Collaboration Suite**: Complete collaboration platform with user management, permissions, and shared workspaces
|
||||
- **Professional Animation Tools**: Timeline-based animation system with keyframes and interpolation
|
||||
- **Cloud Integration**: Seamless cloud storage integration with automatic backup and sync
|
||||
- **Advanced Debugging Tools**: Comprehensive debugging suite with step-through execution and variable inspection`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EmptyContent: Story = {
|
||||
args: {
|
||||
releaseData: {
|
||||
id: 5,
|
||||
project: 'comfyui',
|
||||
version: '1.0.0',
|
||||
attention: 'low',
|
||||
published_at: '2024-01-01T00:00:00Z',
|
||||
content: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
283
src/platform/updates/components/ReleaseNotificationToast.test.ts
Normal file
283
src/platform/updates/components/ReleaseNotificationToast.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import ReleaseNotificationToast from './ReleaseNotificationToast.vue'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
locale: { value: 'en' },
|
||||
t: vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'releaseToast.newVersionAvailable': 'New update is out!',
|
||||
'releaseToast.whatsNew': "See what's new",
|
||||
'releaseToast.skip': 'Skip',
|
||||
'releaseToast.update': 'Update',
|
||||
'releaseToast.description':
|
||||
'Check out the latest improvements and features in this update.'
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
})),
|
||||
createI18n: vi.fn(() => ({
|
||||
global: {
|
||||
locale: { value: 'en' }
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
formatVersionAnchor: vi.fn((version: string) => version.replace(/\./g, ''))
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((content: string) => `<div>${content}</div>`)
|
||||
}))
|
||||
|
||||
// Mock release store
|
||||
const mockReleaseStore = {
|
||||
recentRelease: null as ReleaseNote | null,
|
||||
shouldShowToast: false,
|
||||
handleSkipRelease: vi.fn(),
|
||||
handleShowChangelog: vi.fn(),
|
||||
releases: [],
|
||||
fetchReleases: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('../common/releaseStore', () => ({
|
||||
useReleaseStore: vi.fn(() => mockReleaseStore)
|
||||
}))
|
||||
|
||||
describe('ReleaseNotificationToast', () => {
|
||||
let wrapper: VueWrapper<InstanceType<typeof ReleaseNotificationToast>>
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(ReleaseNotificationToast, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'releaseToast.newVersionAvailable': 'New update is out!',
|
||||
'releaseToast.whatsNew': "See what's new",
|
||||
'releaseToast.skip': 'Skip',
|
||||
'releaseToast.update': 'Update',
|
||||
'releaseToast.description':
|
||||
'Check out the latest improvements and features in this update.'
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
},
|
||||
stubs: {
|
||||
// Stub Lucide icons
|
||||
'i-lucide-rocket': true,
|
||||
'i-lucide-external-link': true
|
||||
}
|
||||
},
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset store state
|
||||
mockReleaseStore.recentRelease = null
|
||||
mockReleaseStore.shouldShowToast = true // Force show for testing
|
||||
})
|
||||
|
||||
it('renders correctly when shouldShow is true', () => {
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release\n\nSome content'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays rocket icon', () => {
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
expect(wrapper.find('.icon-\\[lucide--rocket\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays release version', () => {
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('1.2.3')
|
||||
})
|
||||
|
||||
it('calls handleSkipRelease when skip button is clicked', async () => {
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const skipButton = buttons.find(
|
||||
(btn) =>
|
||||
btn.text().includes('Skip') || btn.element.innerHTML.includes('skip')
|
||||
)
|
||||
expect(skipButton).toBeDefined()
|
||||
await skipButton!.trigger('click')
|
||||
|
||||
expect(mockReleaseStore.handleSkipRelease).toHaveBeenCalledWith('1.2.3')
|
||||
})
|
||||
|
||||
it('opens update URL when update button is clicked', async () => {
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
// Mock window.open
|
||||
const mockWindowOpen = vi.fn()
|
||||
Object.defineProperty(window, 'open', {
|
||||
value: mockWindowOpen,
|
||||
writable: true
|
||||
})
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Call the handler directly instead of triggering DOM event
|
||||
await wrapper.vm.handleUpdate()
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://docs.comfy.org/installation/update_comfyui',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('calls handleShowChangelog when learn more link is clicked', async () => {
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Call the handler directly instead of triggering DOM event
|
||||
await wrapper.vm.handleLearnMore()
|
||||
|
||||
expect(mockReleaseStore.handleShowChangelog).toHaveBeenCalledWith('1.2.3')
|
||||
})
|
||||
|
||||
it('generates correct changelog URL', () => {
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
const learnMoreLink = wrapper.find('a[target="_blank"]')
|
||||
expect(learnMoreLink.exists()).toBe(true)
|
||||
expect(learnMoreLink.attributes('href')).toContain(
|
||||
'docs.comfy.org/changelog'
|
||||
)
|
||||
})
|
||||
|
||||
it('removes title from markdown content for toast display', async () => {
|
||||
const mockMarkdownRendererModule = (await vi.importMock(
|
||||
'@/utils/markdownRendererUtil'
|
||||
)) as { renderMarkdownToHtml: ReturnType<typeof vi.fn> }
|
||||
const mockMarkdownRenderer = vi.mocked(
|
||||
mockMarkdownRendererModule.renderMarkdownToHtml
|
||||
)
|
||||
mockMarkdownRenderer.mockReturnValue('<div>Content without title</div>')
|
||||
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release Title\n\nSome content'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Should call markdown renderer with title removed
|
||||
expect(mockMarkdownRenderer).toHaveBeenCalledWith('\n\nSome content')
|
||||
})
|
||||
|
||||
it('fetches releases on mount when not already loaded', async () => {
|
||||
mockReleaseStore.releases = [] // Empty releases array
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles missing release content gracefully', () => {
|
||||
mockReleaseStore.shouldShowToast = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: ''
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Should render fallback content
|
||||
const descriptionElement = wrapper.find('.pl-14')
|
||||
expect(descriptionElement.exists()).toBe(true)
|
||||
expect(descriptionElement.text()).toContain('Check out the latest')
|
||||
})
|
||||
|
||||
it('auto-hides after timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Initially visible
|
||||
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
|
||||
|
||||
// Fast-forward time to trigger auto-hide
|
||||
vi.advanceTimersByTime(8000)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Component should call dismissToast internally which hides it
|
||||
// We can't test DOM visibility change because the component uses local state
|
||||
// But we can verify the timer was set and would have triggered
|
||||
expect(vi.getTimerCount()).toBe(0) // Timer should be cleared after auto-hide
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('clears auto-hide timer when manually dismissed', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Start the timer
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
// Manually dismiss by calling handler directly
|
||||
await wrapper.vm.handleSkip()
|
||||
|
||||
// Timer should be cleared
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
// Verify the store method was called (manual dismissal)
|
||||
expect(mockReleaseStore.handleSkipRelease).toHaveBeenCalled()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -1,43 +1,63 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="release-toast-popup">
|
||||
<div class="release-notification-toast">
|
||||
<!-- Header section with icon and text -->
|
||||
<div class="toast-header">
|
||||
<div class="toast-icon">
|
||||
<i class="pi pi-download" />
|
||||
</div>
|
||||
<div class="toast-text">
|
||||
<div class="toast-title">
|
||||
{{ $t('releaseToast.newVersionAvailable') }}
|
||||
<div
|
||||
class="w-96 max-h-96 bg-base-background border border-border-default rounded-lg shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)] flex flex-col"
|
||||
>
|
||||
<!-- Main content -->
|
||||
<div class="p-4 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<!-- Header section with icon and text -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="p-3 bg-primary-background-hover rounded-lg flex items-center justify-center shrink-0"
|
||||
>
|
||||
<i class="icon-[lucide--rocket] w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div class="toast-version-badge">
|
||||
{{ latestRelease?.version }}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="text-sm font-normal text-base-foreground leading-[1.429]"
|
||||
>
|
||||
{{ $t('releaseToast.newVersionAvailable') }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-normal text-muted-foreground leading-[1.21]"
|
||||
>
|
||||
{{ latestRelease?.version }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description section -->
|
||||
<div
|
||||
class="pl-14 text-sm font-normal text-muted-foreground leading-[1.21] overflow-y-auto flex-1 min-h-0"
|
||||
v-html="formattedContent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Actions section -->
|
||||
<div class="toast-actions-section">
|
||||
<div class="actions-row">
|
||||
<div class="left-actions">
|
||||
<a
|
||||
class="learn-more-link"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener,noreferrer"
|
||||
@click="handleLearnMore"
|
||||
>
|
||||
{{ $t('releaseToast.whatsNew') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="right-actions">
|
||||
<button class="skip-button" @click="handleSkip">
|
||||
{{ $t('releaseToast.skip') }}
|
||||
</button>
|
||||
<button class="cta-button" @click="handleUpdate">
|
||||
{{ $t('releaseToast.update') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Footer section -->
|
||||
<div class="flex justify-between items-center px-4 pb-4">
|
||||
<a
|
||||
class="flex items-center gap-2 text-sm font-normal py-1 text-muted-foreground hover:text-base-foreground"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="handleLearnMore"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] w-4 h-4"></i>
|
||||
{{ $t('releaseToast.whatsNew') }}
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="h-6 px-0 bg-transparent border-none text-sm font-normal text-muted-foreground hover:text-base-foreground cursor-pointer"
|
||||
@click="handleSkip"
|
||||
>
|
||||
{{ $t('releaseToast.skip') }}
|
||||
</button>
|
||||
<button
|
||||
class="h-10 px-4 bg-secondary-background hover:bg-secondary-background-hover rounded-lg border-none text-sm font-normal text-base-foreground cursor-pointer"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
{{ $t('releaseToast.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,24 +65,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import { useReleaseStore } from '../common/releaseStore'
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const releaseStore = useReleaseStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Local state for dismissed status
|
||||
const isDismissed = ref(false)
|
||||
|
||||
// Get latest release from store
|
||||
const latestRelease = computed<ReleaseNote | null>(
|
||||
() => releaseStore.recentRelease
|
||||
)
|
||||
const latestRelease = computed<ReleaseNote | null>(() => {
|
||||
return releaseStore.recentRelease
|
||||
})
|
||||
|
||||
// Show toast when new version available and not dismissed
|
||||
const shouldShow = computed(
|
||||
@@ -79,6 +103,38 @@ const changelogUrl = computed(() => {
|
||||
return changelogBaseUrl
|
||||
})
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
if (!latestRelease.value?.content) {
|
||||
return DOMPurify.sanitize(`<p>${t('releaseToast.description')}</p>`)
|
||||
}
|
||||
|
||||
try {
|
||||
const markdown = latestRelease.value.content
|
||||
// Remove the h1 title line and images for toast mode
|
||||
const contentWithoutTitle = markdown.replace(/^# .+$/m, '')
|
||||
const contentWithoutImages = contentWithoutTitle.replace(
|
||||
/!\[.*?\]\(.*?\)/g,
|
||||
''
|
||||
)
|
||||
|
||||
// Check if there's meaningful content left after cleanup
|
||||
const trimmedContent = contentWithoutImages.trim()
|
||||
if (!trimmedContent || trimmedContent.replace(/\s+/g, '') === '') {
|
||||
return DOMPurify.sanitize(`<p>${t('releaseToast.description')}</p>`)
|
||||
}
|
||||
|
||||
// renderMarkdownToHtml already sanitizes with DOMPurify, so this is safe
|
||||
return renderMarkdownToHtml(contentWithoutImages)
|
||||
} catch (error) {
|
||||
console.error('Error parsing markdown:', error)
|
||||
// Fallback to plain text with line breaks - sanitize the HTML we create
|
||||
const fallbackContent = latestRelease.value.content.replace(/\n/g, '<br>')
|
||||
return fallbackContent.trim()
|
||||
? DOMPurify.sanitize(fallbackContent)
|
||||
: DOMPurify.sanitize(`<p>${t('releaseToast.description')}</p>`)
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-hide timer
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -124,8 +180,6 @@ const handleUpdate = () => {
|
||||
dismissToast()
|
||||
}
|
||||
|
||||
// Learn more handled by anchor href
|
||||
|
||||
// Start auto-hide when toast becomes visible
|
||||
watch(shouldShow, (isVisible) => {
|
||||
if (isVisible) {
|
||||
@@ -142,6 +196,13 @@ onMounted(async () => {
|
||||
await releaseStore.fetchReleases()
|
||||
}
|
||||
})
|
||||
|
||||
// Expose methods for testing
|
||||
defineExpose({
|
||||
handleSkip,
|
||||
handleLearnMore,
|
||||
handleUpdate
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -154,10 +215,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
/* Sidebar positioning classes applied by parent - matching help center */
|
||||
.release-toast-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.release-toast-popup.sidebar-left,
|
||||
.release-toast-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
@@ -165,139 +223,4 @@ onMounted(async () => {
|
||||
.release-toast-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
/* Main toast container */
|
||||
.release-notification-toast {
|
||||
width: 448px;
|
||||
padding: 16px 16px 8px;
|
||||
background: #353535;
|
||||
box-shadow: 0 4px 4px rgb(0 0 0 / 0.25);
|
||||
border-radius: 12px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Header section */
|
||||
.toast-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Icon container */
|
||||
.toast-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 10px;
|
||||
background: rgb(0 122 255 / 0.2);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-icon i {
|
||||
color: #007aff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Text content */
|
||||
.toast-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 18.2px;
|
||||
}
|
||||
|
||||
.toast-version-badge {
|
||||
color: #a0a1a2;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 15.6px;
|
||||
}
|
||||
|
||||
/* Actions section */
|
||||
.toast-actions-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
padding-left: 58px; /* Align with text content */
|
||||
padding-right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.left-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Learn more link - simple text link */
|
||||
.learn-more-link {
|
||||
color: #60a5fa;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 15.6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.learn-more-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.skip-button {
|
||||
padding: 8px 16px;
|
||||
background: #353535;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
border: none;
|
||||
color: #aeaeb2;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skip-button:hover {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
border: none;
|
||||
color: black;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
211
src/platform/updates/components/WhatsNewPopup.stories.ts
Normal file
211
src/platform/updates/components/WhatsNewPopup.stories.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import { useReleaseStore } from '../common/releaseStore'
|
||||
import WhatsNewPopup from './WhatsNewPopup.vue'
|
||||
|
||||
// Mock release data with realistic CMS content
|
||||
const mockReleases: ReleaseNote[] = [
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: '1.2.3',
|
||||
attention: 'medium',
|
||||
published_at: '2024-01-15T10:00:00Z',
|
||||
content: `# ComfyUI 1.2.3 Release
|
||||
|
||||
**What's new**
|
||||
|
||||
New features and improvements for better workflow management.
|
||||
|
||||
- **Enhanced Node Editor**: Improved performance for large workflows with 100+ nodes
|
||||
- **Auto-save Feature**: Your work is now automatically saved every 30 seconds
|
||||
- **New Model Support**: Added support for FLUX.1-dev and FLUX.1-schnell models
|
||||
- **Bug Fixes**: Resolved memory leak issues in the backend processing`
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
project: 'comfyui',
|
||||
version: '1.2.4',
|
||||
attention: 'high',
|
||||
published_at: '2024-02-01T14:30:00Z',
|
||||
content: `
|
||||
|
||||
# ComfyUI 1.2.4 Major Release
|
||||
|
||||
**What's new**
|
||||
|
||||
Revolutionary updates that change how you create with ComfyUI.
|
||||
|
||||
- **Real-time Collaboration**: Share and edit workflows with your team in real-time
|
||||
- **Advanced Upscaling**: New ESRGAN and Real-ESRGAN models built-in
|
||||
- **Custom Node Store**: Browse and install community nodes directly from the interface
|
||||
- **Performance Boost**: 40% faster generation times for SDXL models
|
||||
- **Dark Mode**: Beautiful new dark interface theme`
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
project: 'comfyui',
|
||||
version: '1.3.0',
|
||||
attention: 'high',
|
||||
published_at: '2024-03-10T09:15:00Z',
|
||||
content: `
|
||||
|
||||
# ComfyUI 1.3.0 - The Biggest Update Yet
|
||||
|
||||
**What's new**
|
||||
|
||||
Introducing powerful new features that unlock creative possibilities.
|
||||
|
||||
- **AI-Powered Node Suggestions**: Get intelligent recommendations while building workflows
|
||||
- **Workflow Templates**: Start from professionally designed templates
|
||||
- **Advanced Queuing**: Batch process multiple generations with queue management
|
||||
- **Mobile Preview**: Preview your workflows on mobile devices
|
||||
- **API Improvements**: Enhanced REST API with better documentation
|
||||
- **Community Hub**: Share workflows and discover creations from other users`
|
||||
}
|
||||
]
|
||||
|
||||
interface StoryArgs {
|
||||
releaseData: ReleaseNote
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Platform/Updates/WhatsNewPopup',
|
||||
component: WhatsNewPopup,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
argTypes: {
|
||||
releaseData: {
|
||||
control: 'object',
|
||||
description: 'Release data with version and markdown content'
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(_story, context) => {
|
||||
// Set up the store with mock data for this story
|
||||
const releaseStore = useReleaseStore()
|
||||
|
||||
// Override store data with story args
|
||||
releaseStore.releases = [context.args.releaseData]
|
||||
|
||||
// Force the computed properties to return the values we want
|
||||
Object.defineProperty(releaseStore, 'recentRelease', {
|
||||
value: context.args.releaseData,
|
||||
writable: true
|
||||
})
|
||||
Object.defineProperty(releaseStore, 'shouldShowPopup', {
|
||||
value: true,
|
||||
writable: true
|
||||
})
|
||||
|
||||
// Mock the store methods to prevent errors
|
||||
releaseStore.handleWhatsNewSeen = async () => {
|
||||
// Mock implementation for Storybook
|
||||
}
|
||||
|
||||
return {
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-900 p-8">
|
||||
<story />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
releaseData: mockReleases[0]
|
||||
}
|
||||
}
|
||||
|
||||
export const WithImage: Story = {
|
||||
args: {
|
||||
releaseData: mockReleases[1]
|
||||
}
|
||||
}
|
||||
|
||||
export const MajorRelease: Story = {
|
||||
args: {
|
||||
releaseData: mockReleases[2]
|
||||
}
|
||||
}
|
||||
|
||||
export const LongContent: Story = {
|
||||
args: {
|
||||
releaseData: {
|
||||
id: 4,
|
||||
project: 'comfyui',
|
||||
version: '2.0.0',
|
||||
attention: 'high',
|
||||
published_at: '2024-04-20T16:00:00Z',
|
||||
content: `
|
||||
|
||||
# ComfyUI 2.0.0 - Complete Rewrite
|
||||
|
||||
**What's new**
|
||||
|
||||
The most significant update in ComfyUI history with complete platform rewrite.
|
||||
|
||||
## Core Engine Improvements
|
||||
|
||||
- **Next-Generation Workflow Engine**: Completely rewritten from the ground up with 500% performance improvements for complex workflows
|
||||
- **Advanced Memory Management**: Intelligent memory allocation reducing VRAM usage by up to 60% while maintaining quality
|
||||
- **Multi-Threading Support**: Full multi-core CPU utilization for preprocessing and post-processing tasks
|
||||
- **GPU Optimization**: Advanced GPU scheduling with automatic optimization for different hardware configurations
|
||||
|
||||
## New User Interface
|
||||
|
||||
- **Modern Design Language**: Beautiful new interface with improved accessibility and mobile responsiveness
|
||||
- **Customizable Workspace**: Fully customizable layout with dockable panels and saved workspace configurations
|
||||
- **Advanced Node Browser**: Intelligent node search with AI-powered suggestions and visual node previews
|
||||
- **Real-time Preview**: Live preview of changes as you build your workflow without needing to execute
|
||||
|
||||
## Professional Features
|
||||
|
||||
- **Version Control Integration**: Native Git integration for workflow version control and collaboration
|
||||
- **Enterprise Security**: Advanced security features including end-to-end encryption and audit logging
|
||||
- **Scalable Architecture**: Designed to handle enterprise-scale deployments with thousands of concurrent users
|
||||
- **Plugin Ecosystem**: Robust plugin system with hot-loading and automatic dependency management`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MinimalContent: Story = {
|
||||
args: {
|
||||
releaseData: {
|
||||
id: 5,
|
||||
project: 'comfyui',
|
||||
version: '1.0.1',
|
||||
attention: 'low',
|
||||
published_at: '2024-01-05T12:00:00Z',
|
||||
content: `# ComfyUI 1.0.1
|
||||
|
||||
**What's new**
|
||||
|
||||
Quick patch release.
|
||||
|
||||
- **Bug Fix**: Fixed critical save issue`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EmptyContent: Story = {
|
||||
args: {
|
||||
releaseData: {
|
||||
id: 6,
|
||||
project: 'comfyui',
|
||||
version: '1.0.0',
|
||||
attention: 'low',
|
||||
published_at: '2024-01-01T00:00:00Z',
|
||||
content: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
213
src/platform/updates/components/WhatsNewPopup.test.ts
Normal file
213
src/platform/updates/components/WhatsNewPopup.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import WhatsNewPopup from './WhatsNewPopup.vue'
|
||||
|
||||
// Mock dependencies
|
||||
const mockTranslations: Record<string, string> = {
|
||||
'g.close': 'Close',
|
||||
'whatsNewPopup.later': 'Later',
|
||||
'whatsNewPopup.learnMore': 'Learn More',
|
||||
'whatsNewPopup.noReleaseNotes': 'No release notes available'
|
||||
}
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
locale: {
|
||||
value: 'en'
|
||||
}
|
||||
}
|
||||
},
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
return params
|
||||
? `${mockTranslations[key] || key}:${JSON.stringify(params)}`
|
||||
: mockTranslations[key] || key
|
||||
},
|
||||
d: (date: Date) => date.toLocaleDateString()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
locale: { value: 'en' },
|
||||
t: vi.fn((key: string) => {
|
||||
return mockTranslations[key] || key
|
||||
})
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
formatVersionAnchor: vi.fn((version: string) => version.replace(/\./g, ''))
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((content: string) => `<div>${content}</div>`)
|
||||
}))
|
||||
|
||||
// Mock release store
|
||||
const mockReleaseStore = {
|
||||
recentRelease: null as ReleaseNote | null,
|
||||
shouldShowPopup: false,
|
||||
handleWhatsNewSeen: vi.fn(),
|
||||
releases: [] as ReleaseNote[],
|
||||
fetchReleases: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('../common/releaseStore', () => ({
|
||||
useReleaseStore: vi.fn(() => mockReleaseStore)
|
||||
}))
|
||||
|
||||
describe('WhatsNewPopup', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(WhatsNewPopup, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Button },
|
||||
mocks: {
|
||||
$t: (key: string) => {
|
||||
return mockTranslations[key] || key
|
||||
}
|
||||
},
|
||||
stubs: {
|
||||
// Stub Lucide icons
|
||||
'i-lucide-x': true,
|
||||
'i-lucide-external-link': true
|
||||
}
|
||||
},
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset store state
|
||||
mockReleaseStore.recentRelease = null
|
||||
mockReleaseStore.shouldShowPopup = false
|
||||
mockReleaseStore.releases = []
|
||||
mockReleaseStore.handleWhatsNewSeen = vi.fn()
|
||||
mockReleaseStore.fetchReleases = vi.fn()
|
||||
})
|
||||
|
||||
it('renders correctly when shouldShow is true', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release\n\nSome content'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
expect(wrapper.find('.whats-new-popup').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render when shouldShow is false', () => {
|
||||
mockReleaseStore.shouldShowPopup = false
|
||||
wrapper = mountComponent()
|
||||
expect(wrapper.find('.whats-new-popup').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('calls handleWhatsNewSeen when close button is clicked', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
const closeButton = wrapper.findComponent(Button)
|
||||
await closeButton.trigger('click')
|
||||
|
||||
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.2.3')
|
||||
})
|
||||
|
||||
it('generates correct changelog URL', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
const learnMoreLink = wrapper.find('.learn-more-link')
|
||||
expect(learnMoreLink.attributes('href')).toContain(
|
||||
'docs.comfy.org/changelog'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles missing release content gracefully', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: ''
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Should render fallback content
|
||||
const contentElement = wrapper.find('.content-text')
|
||||
expect(contentElement.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits whats-new-dismissed event when popup is closed', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Test Release'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Call the close method directly instead of triggering DOM event
|
||||
await (wrapper.vm as any).closePopup()
|
||||
|
||||
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('fetches releases on mount when not already loaded', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.releases = [] // Empty releases array
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fetch releases when already loaded', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.releases = [{ version: '1.0.0' } as ReleaseNote] // Non-empty releases array
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
expect(mockReleaseStore.fetchReleases).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('processes markdown content correctly', async () => {
|
||||
const mockMarkdownRendererModule = (await vi.importMock(
|
||||
'@/utils/markdownRendererUtil'
|
||||
)) as { renderMarkdownToHtml: ReturnType<typeof vi.fn> }
|
||||
const mockMarkdownRenderer = vi.mocked(
|
||||
mockMarkdownRendererModule.renderMarkdownToHtml
|
||||
)
|
||||
mockMarkdownRenderer.mockReturnValue('<h1>Processed Content</h1>')
|
||||
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
version: '1.2.3',
|
||||
content: '# Original Title\n\nContent'
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Should call markdown renderer with original content (no modification)
|
||||
expect(mockMarkdownRenderer).toHaveBeenCalledWith(
|
||||
'# Original Title\n\nContent'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,62 +1,50 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="whats-new-popup-container">
|
||||
<!-- Arrow pointing to help center -->
|
||||
<div class="help-center-arrow">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="19"
|
||||
viewBox="0 0 16 19"
|
||||
fill="none"
|
||||
>
|
||||
<!-- Arrow fill -->
|
||||
<path
|
||||
d="M15.25 1.27246L15.25 17.7275L0.999023 9.5L15.25 1.27246Z"
|
||||
fill="#353535"
|
||||
/>
|
||||
<!-- Top and bottom outlines only -->
|
||||
<path
|
||||
d="M15.25 1.27246L0.999023 9.5"
|
||||
stroke="#4e4e4e"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M0.999023 9.5L15.25 17.7275"
|
||||
stroke="#4e4e4e"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-if="shouldShow" class="whats-new-popup-container left-4">
|
||||
<div class="whats-new-popup" @click.stop>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
class="close-button"
|
||||
<Button
|
||||
class="close-button absolute top-2 right-2 z-10 w-8 h-8 p-2 rounded-lg opacity-50"
|
||||
:aria-label="$t('g.close')"
|
||||
icon="icon-[lucide--x]"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="closePopup"
|
||||
/>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="modal-body flex flex-col gap-4 px-0 pt-0 pb-2 flex-1">
|
||||
<!-- Release Content -->
|
||||
<div
|
||||
class="content-text max-h-96 overflow-y-auto"
|
||||
v-html="formattedContent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div
|
||||
class="modal-footer flex justify-between items-center gap-4 px-4 pb-4"
|
||||
>
|
||||
<div class="close-icon"></div>
|
||||
</button>
|
||||
|
||||
<!-- Release Content -->
|
||||
<div class="popup-content">
|
||||
<div class="content-text" v-html="formattedContent"></div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div class="popup-actions">
|
||||
<a
|
||||
class="learn-more-link"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener,noreferrer"
|
||||
<a
|
||||
class="learn-more-link flex items-center gap-2 text-sm font-normal py-1"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="closePopup"
|
||||
>
|
||||
<i class="icon-[lucide--external-link]"></i>
|
||||
{{ $t('whatsNewPopup.learnMore') }}
|
||||
</a>
|
||||
<div class="footer-actions flex items-center gap-4">
|
||||
<Button
|
||||
class="h-8"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="closePopup"
|
||||
>
|
||||
{{ $t('whatsNewPopup.learnMore') }}
|
||||
</a>
|
||||
<!-- TODO: CTA button -->
|
||||
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
|
||||
{{ $t('whatsNewPopup.later') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,6 +52,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -74,9 +64,9 @@ import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import { useReleaseStore } from '../common/releaseStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const releaseStore = useReleaseStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Define emits
|
||||
const emit = defineEmits<{
|
||||
@@ -87,9 +77,9 @@ const emit = defineEmits<{
|
||||
const isDismissed = ref(false)
|
||||
|
||||
// Get latest release from store
|
||||
const latestRelease = computed<ReleaseNote | null>(
|
||||
() => releaseStore.recentRelease
|
||||
)
|
||||
const latestRelease = computed<ReleaseNote | null>(() => {
|
||||
return releaseStore.recentRelease
|
||||
})
|
||||
|
||||
// Show popup when on latest version and not dismissed
|
||||
const shouldShow = computed(
|
||||
@@ -108,15 +98,39 @@ const changelogUrl = computed(() => {
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
if (!latestRelease.value?.content) {
|
||||
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
|
||||
return DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
|
||||
}
|
||||
|
||||
try {
|
||||
return renderMarkdownToHtml(latestRelease.value.content)
|
||||
const markdown = latestRelease.value.content
|
||||
|
||||
// Check if content is meaningful (not just whitespace)
|
||||
const trimmedContent = markdown.trim()
|
||||
if (!trimmedContent || trimmedContent.replace(/\s+/g, '') === '') {
|
||||
return DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
|
||||
}
|
||||
|
||||
// Extract image and remaining content separately
|
||||
const imageMatch = markdown.match(/!\[.*?\]\(.*?\)/)
|
||||
const image = imageMatch ? imageMatch[0] : ''
|
||||
|
||||
// Remove image from content but keep original title
|
||||
const contentWithoutImage = markdown.replace(/!\[.*?\]\(.*?\)/, '').trim()
|
||||
|
||||
// Reorder: image first, then original content
|
||||
const reorderedContent = [image, contentWithoutImage]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
// renderMarkdownToHtml already sanitizes with DOMPurify, so this is safe
|
||||
return renderMarkdownToHtml(reorderedContent)
|
||||
} catch (error) {
|
||||
console.error('Error parsing markdown:', error)
|
||||
// Fallback to plain text with line breaks
|
||||
return latestRelease.value.content.replace(/\n/g, '<br>')
|
||||
// Fallback to plain text with line breaks - sanitize the HTML we create
|
||||
const fallbackContent = latestRelease.value.content.replace(/\n/g, '<br>')
|
||||
return fallbackContent.trim()
|
||||
? DOMPurify.sanitize(fallbackContent)
|
||||
: DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -145,10 +159,11 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Expose methods for parent component
|
||||
// Expose methods for parent component and tests
|
||||
defineExpose({
|
||||
show,
|
||||
hide
|
||||
hide,
|
||||
closePopup
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -163,165 +178,80 @@ defineExpose({
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Arrow pointing to help center */
|
||||
.help-center-arrow {
|
||||
position: absolute;
|
||||
bottom: calc(
|
||||
var(--sidebar-width) * 2 + var(--sidebar-width) / 2
|
||||
); /* Position to center of help center icon (2 icons below + half icon height for center) */
|
||||
transform: none;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Position arrow based on sidebar location */
|
||||
.whats-new-popup-container.sidebar-left .help-center-arrow {
|
||||
left: -14px; /* Overlap with popup outline */
|
||||
}
|
||||
|
||||
.whats-new-popup-container.sidebar-left.small-sidebar .help-center-arrow {
|
||||
left: -14px; /* Overlap with popup outline */
|
||||
bottom: calc(
|
||||
var(--sidebar-width) * 2 + var(--sidebar-icon-size) / 2 -
|
||||
var(--whats-new-popup-bottom)
|
||||
); /* Position to center of help center icon (2 icons below + half icon height for center - what's new popup bottom position ) */
|
||||
}
|
||||
|
||||
/* Sidebar positioning classes applied by parent */
|
||||
.whats-new-popup-container.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-popup-container.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-popup-container.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-popup {
|
||||
background: #353535;
|
||||
border-radius: 12px;
|
||||
background: var(--interface-menu-surface);
|
||||
border-radius: 8px;
|
||||
max-width: 400px;
|
||||
width: 400px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
box-shadow: 0 8px 32px rgb(0 0 0 / 0.3);
|
||||
border: 1px solid var(--interface-menu-stroke);
|
||||
box-shadow: 1px 1px 8px 0 rgb(0 0 0 / 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Content Section */
|
||||
.popup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 32px 32px 24px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
background: #7c7c7c;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
/* Modal Body */
|
||||
.modal-body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transform: translate(30%, -30%);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
z-index: 1;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #8e8e8e;
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
background: #6a6a6a;
|
||||
transform: translate(30%, -30%) scale(0.95);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover .close-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-icon::before,
|
||||
.close-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
background: white;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.close-icon::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
.modal-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Style the markdown content */
|
||||
/* Title */
|
||||
.content-text :deep(*) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(h1) {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Version subtitle - targets the first p tag after h1 */
|
||||
.content-text :deep(h1 + p) {
|
||||
color: #c0c0c0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.8;
|
||||
/* What's new title - targets h2 or strong text after h1 */
|
||||
.content-text :deep(h2),
|
||||
.content-text :deep(h1 + p strong) {
|
||||
color: var(--text-primary);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.429;
|
||||
}
|
||||
|
||||
/* Regular paragraphs - short description */
|
||||
.content-text :deep(p) {
|
||||
margin-bottom: 16px;
|
||||
color: #e0e0e0;
|
||||
color: var(--text-secondary);
|
||||
font-family: Inter, sans-serif;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* List */
|
||||
.content-text :deep(ul),
|
||||
.content-text :deep(ol) {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
@@ -336,110 +266,168 @@ defineExpose({
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* List items */
|
||||
.content-text :deep(li) {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
padding-left: 18px;
|
||||
color: var(--text-secondary);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2102;
|
||||
}
|
||||
|
||||
.content-text :deep(li:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Custom bullet points */
|
||||
.content-text :deep(li::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
display: flex;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 100px;
|
||||
background: #60a5fa;
|
||||
left: 4px;
|
||||
top: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: 2px solid var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* List item strong text */
|
||||
.content-text :deep(li strong) {
|
||||
color: #fff;
|
||||
color: var(--text-secondary);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2102;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.content-text :deep(li p) {
|
||||
font-size: 12px;
|
||||
margin-bottom: 0;
|
||||
line-height: 2;
|
||||
margin: 2px 0 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Code styling */
|
||||
.content-text :deep(code) {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #4a4a4a;
|
||||
background-color: var(--input-surface);
|
||||
border: 1px solid var(--interface-menu-stroke);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
color: #f8f8f2;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Remove top margin for first media element */
|
||||
.content-text :deep(img:first-child),
|
||||
.content-text :deep(video:first-child),
|
||||
.content-text :deep(iframe:first-child) {
|
||||
margin-top: -32px; /* Align with the top edge of the popup content */
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Media elements */
|
||||
.content-text :deep(img),
|
||||
.content-text :deep(video),
|
||||
.content-text :deep(iframe) {
|
||||
width: calc(100% + 64px);
|
||||
height: auto;
|
||||
margin: 24px -32px;
|
||||
.content-text :deep(img) {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
margin: 0 0 16px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Actions Section */
|
||||
.popup-actions {
|
||||
.content-text :deep(img:first-child) {
|
||||
margin: -1rem -1rem 16px;
|
||||
width: calc(100% + 2rem);
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
/* Add border to content when image is present */
|
||||
.content-text:has(img:first-child) {
|
||||
border-left: 1px solid var(--interface-menu-stroke);
|
||||
border-right: 1px solid var(--interface-menu-stroke);
|
||||
border-top: 1px solid var(--interface-menu-stroke);
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
margin: -1px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(img + h1) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Secondary headings */
|
||||
.content-text :deep(h3) {
|
||||
color: var(--text-primary);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Modal Footer */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.learn-more-link {
|
||||
color: #60a5fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 18.2px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2102;
|
||||
text-decoration: none;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.learn-more-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
.learn-more-link i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
height: 32px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #121212;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
line-height: 1.2102;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.action-secondary:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
height: 40px;
|
||||
padding: 8px 16px;
|
||||
background: var(--interface-menu-component-surface-hovered);
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2102;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: #f0f0f0;
|
||||
.action-primary:hover {
|
||||
background: var(--button-hover-surface);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,11 +33,9 @@ export function useTemplateUrlLoader() {
|
||||
|
||||
/**
|
||||
* Validates parameter format to prevent path traversal and injection attacks
|
||||
* Allows: letters, numbers, underscores, hyphens, and dots (for version numbers)
|
||||
* Blocks: path separators (/, \), special chars that could enable injection
|
||||
*/
|
||||
const isValidParameter = (param: string): boolean => {
|
||||
return /^[a-zA-Z0-9_.-]+$/.test(param)
|
||||
return /^[a-zA-Z0-9_-]+$/.test(param)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,8 @@ export function useNodePointerInteractions(
|
||||
return true
|
||||
}
|
||||
|
||||
let hasDraggingStarted = false
|
||||
|
||||
const startPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const DRAG_THRESHOLD = 3 // pixels
|
||||
@@ -57,7 +59,7 @@ export function useNodePointerInteractions(
|
||||
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
|
||||
startDrag(event, nodeId)
|
||||
safeDragStart(event, nodeId)
|
||||
}
|
||||
|
||||
function onPointermove(event: PointerEvent) {
|
||||
@@ -78,7 +80,7 @@ export function useNodePointerInteractions(
|
||||
if (lmbDown && multiSelect && !layoutStore.isDraggingVueNodes.value) {
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
handleNodeSelect(event, nodeId)
|
||||
startDrag(event, nodeId)
|
||||
safeDragStart(event, nodeId)
|
||||
return
|
||||
}
|
||||
// Check if we should start dragging (pointer moved beyond threshold)
|
||||
@@ -102,6 +104,14 @@ export function useNodePointerInteractions(
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
}
|
||||
|
||||
function safeDragStart(event: PointerEvent, nodeId: string) {
|
||||
try {
|
||||
startDrag(event, nodeId)
|
||||
} finally {
|
||||
hasDraggingStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
function safeDragEnd(event: PointerEvent) {
|
||||
try {
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
@@ -109,6 +119,7 @@ export function useNodePointerInteractions(
|
||||
} catch (error) {
|
||||
console.error('Error during endDrag:', error)
|
||||
} finally {
|
||||
hasDraggingStarted = false
|
||||
cleanupDragState()
|
||||
}
|
||||
}
|
||||
@@ -123,9 +134,12 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
const wasDragging = layoutStore.isDraggingVueNodes.value
|
||||
|
||||
if (wasDragging) {
|
||||
if (hasDraggingStarted || wasDragging) {
|
||||
safeDragEnd(event)
|
||||
return
|
||||
|
||||
if (wasDragging) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Skip selection handling for right-click (button 2) - context menu handles its own selection
|
||||
|
||||
@@ -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?.()
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
<div class="overflow-hidden">
|
||||
<Tabs :value="activeTab">
|
||||
<TabList class="scrollbar-hide overflow-x-auto">
|
||||
<Tab v-if="hasCompatibilityIssues" value="warning" class="mr-6 p-2">
|
||||
<Tab
|
||||
v-if="hasCompatibilityIssues"
|
||||
value="warning"
|
||||
class="mr-6 p-2 font-inter"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>⚠️</span>
|
||||
{{ importFailed ? $t('g.error') : $t('g.warning') }}
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab value="description" class="mr-6 p-2">
|
||||
<Tab value="description" class="mr-6 p-2 font-inter">
|
||||
{{ $t('g.description') }}
|
||||
</Tab>
|
||||
<Tab value="nodes" class="p-2">
|
||||
<Tab value="nodes" class="p-2 font-inter">
|
||||
{{ $t('g.nodes') }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
@@ -73,51 +73,83 @@ function mountAssetFilterBar(props = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions to find filters by user-facing attributes
|
||||
function findFileFormatsFilter(
|
||||
wrapper: ReturnType<typeof mountAssetFilterBar>
|
||||
) {
|
||||
return wrapper.findComponent(
|
||||
'[data-component-id="asset-filter-file-formats"]'
|
||||
)
|
||||
}
|
||||
|
||||
function findBaseModelsFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
|
||||
return wrapper.findComponent('[data-component-id="asset-filter-base-models"]')
|
||||
}
|
||||
|
||||
function findOwnershipFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
|
||||
return wrapper.findComponent('[data-component-id="asset-filter-ownership"]')
|
||||
}
|
||||
|
||||
function findSortFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
|
||||
return wrapper.findComponent('[data-component-id="asset-filter-sort"]')
|
||||
}
|
||||
|
||||
describe('AssetFilterBar', () => {
|
||||
describe('Filter State Management', () => {
|
||||
it('handles multiple simultaneous filter changes correctly', async () => {
|
||||
// Provide assets with options so filters are visible
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificBaseModel('sd15')
|
||||
createAssetWithSpecificExtension('ckpt'),
|
||||
createAssetWithSpecificBaseModel('sd15'),
|
||||
createAssetWithSpecificBaseModel('sdxl')
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
// Update file formats
|
||||
const fileFormatSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[0]
|
||||
await fileFormatSelect.vm.$emit('update:modelValue', [
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.safetensors', value: 'safetensors' }
|
||||
])
|
||||
const fileFormatSelect = findFileFormatsFilter(wrapper)
|
||||
const fileFormatSelectElement = fileFormatSelect.find('select')
|
||||
const options = fileFormatSelectElement.findAll('option')
|
||||
const ckptOption = options.find((o) => o.element.value === 'ckpt')!
|
||||
const safetensorsOption = options.find(
|
||||
(o) => o.element.value === 'safetensors'
|
||||
)!
|
||||
ckptOption.element.selected = true
|
||||
safetensorsOption.element.selected = true
|
||||
await fileFormatSelectElement.trigger('change')
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Update base models
|
||||
const baseModelSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[1]
|
||||
await baseModelSelect.vm.$emit('update:modelValue', [
|
||||
{ name: 'SD XL', value: 'sdxl' }
|
||||
])
|
||||
const baseModelSelect = findBaseModelsFilter(wrapper)
|
||||
const baseModelSelectElement = baseModelSelect.find('select')
|
||||
const sdxlOption = baseModelSelectElement
|
||||
.findAll('option')
|
||||
.find((o) => o.element.value === 'sdxl')
|
||||
sdxlOption!.element.selected = true
|
||||
await baseModelSelectElement.trigger('change')
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Update sort
|
||||
const sortSelect = wrapper.findComponent({ name: 'SingleSelect' })
|
||||
await sortSelect.vm.$emit('update:modelValue', 'popular')
|
||||
const sortSelect = findSortFilter(wrapper)
|
||||
const sortSelectElement = sortSelect.find('select')
|
||||
sortSelectElement.element.value = 'name-desc'
|
||||
await sortSelectElement.trigger('change')
|
||||
|
||||
await nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('filterChange')
|
||||
expect(emitted).toHaveLength(3)
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted!.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Check final state
|
||||
const finalState: FilterState = emitted![2][0] as FilterState
|
||||
const finalState: FilterState = emitted![
|
||||
emitted!.length - 1
|
||||
][0] as FilterState
|
||||
expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors'])
|
||||
expect(finalState.baseModels).toEqual(['sdxl'])
|
||||
expect(finalState.sortBy).toBe('popular')
|
||||
expect(finalState.sortBy).toBe('name-desc')
|
||||
})
|
||||
|
||||
it('ensures FilterState interface compliance', async () => {
|
||||
@@ -128,12 +160,11 @@ describe('AssetFilterBar', () => {
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const fileFormatSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[0]
|
||||
await fileFormatSelect.vm.$emit('update:modelValue', [
|
||||
{ name: '.ckpt', value: 'ckpt' }
|
||||
])
|
||||
const fileFormatSelect = findFileFormatsFilter(wrapper)
|
||||
const fileFormatSelectElement = fileFormatSelect.find('select')
|
||||
const ckptOption = fileFormatSelectElement.findAll('option')[0]
|
||||
ckptOption.element.selected = true
|
||||
await fileFormatSelectElement.trigger('change')
|
||||
|
||||
await nextTick()
|
||||
|
||||
@@ -165,10 +196,11 @@ describe('AssetFilterBar', () => {
|
||||
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const fileFormatSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[0]
|
||||
expect(fileFormatSelect.props('options')).toEqual([
|
||||
const fileFormatSelect = findFileFormatsFilter(wrapper)
|
||||
const options = fileFormatSelect.findAll('option')
|
||||
expect(
|
||||
options.map((o) => ({ name: o.text(), value: o.element.value }))
|
||||
).toEqual([
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.pt', value: 'pt' },
|
||||
{ name: '.safetensors', value: 'safetensors' }
|
||||
@@ -184,10 +216,11 @@ describe('AssetFilterBar', () => {
|
||||
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const baseModelSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[1]
|
||||
expect(baseModelSelect.props('options')).toEqual([
|
||||
const baseModelSelect = findBaseModelsFilter(wrapper)
|
||||
const options = baseModelSelect.findAll('option')
|
||||
expect(
|
||||
options.map((o) => ({ name: o.text(), value: o.element.value }))
|
||||
).toEqual([
|
||||
{ name: 'sd15', value: 'sd15' },
|
||||
{ name: 'sd35', value: 'sd35' },
|
||||
{ name: 'sdxl', value: 'sdxl' }
|
||||
@@ -200,26 +233,16 @@ describe('AssetFilterBar', () => {
|
||||
const assets: AssetItem[] = [] // No assets = no file format options
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const fileFormatSelects = wrapper
|
||||
.findAllComponents({ name: 'MultiSelect' })
|
||||
.filter(
|
||||
(component) => component.props('label') === 'assetBrowser.fileFormats'
|
||||
)
|
||||
|
||||
expect(fileFormatSelects).toHaveLength(0)
|
||||
const fileFormatSelect = findFileFormatsFilter(wrapper)
|
||||
expect(fileFormatSelect.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides base model filter when no options available', () => {
|
||||
const assets = [createAssetWithoutBaseModel()] // Asset without base model = no base model options
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const baseModelSelects = wrapper
|
||||
.findAllComponents({ name: 'MultiSelect' })
|
||||
.filter(
|
||||
(component) => component.props('label') === 'assetBrowser.baseModels'
|
||||
)
|
||||
|
||||
expect(baseModelSelects).toHaveLength(0)
|
||||
const baseModelSelect = findBaseModelsFilter(wrapper)
|
||||
expect(baseModelSelect.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows both filters when options are available', () => {
|
||||
@@ -229,23 +252,106 @@ describe('AssetFilterBar', () => {
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
|
||||
const fileFormatSelect = multiSelects.find(
|
||||
(component) => component.props('label') === 'assetBrowser.fileFormats'
|
||||
)
|
||||
const baseModelSelect = multiSelects.find(
|
||||
(component) => component.props('label') === 'assetBrowser.baseModels'
|
||||
)
|
||||
const fileFormatSelect = findFileFormatsFilter(wrapper)
|
||||
const baseModelSelect = findBaseModelsFilter(wrapper)
|
||||
|
||||
expect(fileFormatSelect).toBeDefined()
|
||||
expect(baseModelSelect).toBeDefined()
|
||||
expect(fileFormatSelect.exists()).toBe(true)
|
||||
expect(baseModelSelect.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides both filters when no assets provided', () => {
|
||||
const wrapper = mountAssetFilterBar()
|
||||
|
||||
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
|
||||
expect(multiSelects).toHaveLength(0)
|
||||
const fileFormatSelect = findFileFormatsFilter(wrapper)
|
||||
const baseModelSelect = findBaseModelsFilter(wrapper)
|
||||
|
||||
expect(fileFormatSelect.exists()).toBe(false)
|
||||
expect(baseModelSelect.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides ownership filter when no mutable assets', () => {
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors', true) // immutable
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const ownershipSelect = findOwnershipFilter(wrapper)
|
||||
expect(ownershipSelect.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows ownership filter when mutable assets exist', () => {
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors', false) // mutable
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const ownershipSelect = findOwnershipFilter(wrapper)
|
||||
expect(ownershipSelect.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows ownership filter when mixed assets exist', () => {
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors', true), // immutable
|
||||
createAssetWithSpecificExtension('ckpt', false) // mutable
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const ownershipSelect = findOwnershipFilter(wrapper)
|
||||
expect(ownershipSelect.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows ownership filter with allAssets when provided', () => {
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors', true) // immutable
|
||||
]
|
||||
const allAssets = [
|
||||
createAssetWithSpecificExtension('safetensors', true), // immutable
|
||||
createAssetWithSpecificExtension('ckpt', false) // mutable
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets, allAssets })
|
||||
|
||||
const ownershipSelect = findOwnershipFilter(wrapper)
|
||||
expect(ownershipSelect.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Ownership Filter', () => {
|
||||
it('emits ownership filter changes', async () => {
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors', false) // mutable
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const ownershipSelect = findOwnershipFilter(wrapper)
|
||||
expect(ownershipSelect.exists()).toBe(true)
|
||||
|
||||
const ownershipSelectElement = ownershipSelect.find('select')
|
||||
ownershipSelectElement.element.value = 'my-models'
|
||||
await ownershipSelectElement.trigger('change')
|
||||
await nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('filterChange')
|
||||
expect(emitted).toBeTruthy()
|
||||
|
||||
const filterState = emitted![emitted!.length - 1][0] as FilterState
|
||||
expect(filterState.ownership).toBe('my-models')
|
||||
})
|
||||
|
||||
it('ownership filter defaults to "all"', async () => {
|
||||
const assets = [
|
||||
createAssetWithSpecificExtension('safetensors', false) // mutable
|
||||
]
|
||||
const wrapper = mountAssetFilterBar({ assets })
|
||||
|
||||
const sortSelect = findSortFilter(wrapper)
|
||||
const sortSelectElement = sortSelect.find('select')
|
||||
sortSelectElement.element.value = 'recent'
|
||||
await sortSelectElement.trigger('change')
|
||||
await nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('filterChange')
|
||||
const filterState = emitted![0][0] as FilterState
|
||||
expect(filterState.ownership).toBe('all')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -249,7 +249,8 @@ describe('useAssetBrowser', () => {
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: ['safetensors'],
|
||||
baseModels: []
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
@@ -284,7 +285,8 @@ describe('useAssetBrowser', () => {
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: ['SDXL']
|
||||
baseModels: ['SDXL'],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
@@ -335,7 +337,12 @@ describe('useAssetBrowser', () => {
|
||||
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
updateFilters({ sortBy: 'name', fileFormats: [], baseModels: [] })
|
||||
updateFilters({
|
||||
sortBy: 'name',
|
||||
fileFormats: [],
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const names = filteredAssets.value.map((asset) => asset.name)
|
||||
@@ -355,7 +362,12 @@ describe('useAssetBrowser', () => {
|
||||
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
updateFilters({ sortBy: 'recent', fileFormats: [], baseModels: [] })
|
||||
updateFilters({
|
||||
sortBy: 'recent',
|
||||
fileFormats: [],
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const dates = filteredAssets.value.map((asset) => asset.created_at)
|
||||
@@ -367,6 +379,92 @@ describe('useAssetBrowser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Ownership filtering', () => {
|
||||
it('filters by ownership - all', async () => {
|
||||
const assets = [
|
||||
createApiAsset({ name: 'my-model.safetensors', is_immutable: false }),
|
||||
createApiAsset({
|
||||
name: 'public-model.safetensors',
|
||||
is_immutable: true
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'another-my-model.safetensors',
|
||||
is_immutable: false
|
||||
})
|
||||
]
|
||||
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('filters by ownership - my models only', async () => {
|
||||
const assets = [
|
||||
createApiAsset({ name: 'my-model.safetensors', is_immutable: false }),
|
||||
createApiAsset({
|
||||
name: 'public-model.safetensors',
|
||||
is_immutable: true
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'another-my-model.safetensors',
|
||||
is_immutable: false
|
||||
})
|
||||
]
|
||||
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: [],
|
||||
ownership: 'my-models'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(2)
|
||||
expect(filteredAssets.value.every((asset) => !asset.is_immutable)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('filters by ownership - public models only', async () => {
|
||||
const assets = [
|
||||
createApiAsset({ name: 'my-model.safetensors', is_immutable: false }),
|
||||
createApiAsset({
|
||||
name: 'public-model.safetensors',
|
||||
is_immutable: true
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'another-public-model.safetensors',
|
||||
is_immutable: true
|
||||
})
|
||||
]
|
||||
|
||||
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: [],
|
||||
ownership: 'public-models'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(2)
|
||||
expect(filteredAssets.value.every((asset) => asset.is_immutable)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic Category Extraction', () => {
|
||||
it('extracts categories from asset tags', () => {
|
||||
const assets = [
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
locale: { value: 'en' },
|
||||
t: vi.fn((key) => key)
|
||||
})),
|
||||
createI18n: vi.fn(() => ({
|
||||
global: {
|
||||
locale: { value: 'en' }
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((content) => `<p>${content}</p>`)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: vi.fn()
|
||||
}))
|
||||
|
||||
describe('WhatsNewPopup', () => {
|
||||
const mockReleaseStore = {
|
||||
recentRelease: null as ReleaseNote | null,
|
||||
shouldShowPopup: false,
|
||||
handleWhatsNewSeen: vi.fn(),
|
||||
releases: [] as ReleaseNote[],
|
||||
fetchReleases: vi.fn()
|
||||
}
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(WhatsNewPopup, {
|
||||
props,
|
||||
global: {
|
||||
mocks: {
|
||||
$t: vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'g.close': 'Close',
|
||||
'whatsNewPopup.noReleaseNotes': 'No release notes available'
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset mock store
|
||||
mockReleaseStore.recentRelease = null
|
||||
mockReleaseStore.shouldShowPopup = false
|
||||
mockReleaseStore.releases = []
|
||||
|
||||
// Mock release store
|
||||
const { useReleaseStore } = await import(
|
||||
'@/platform/updates/common/releaseStore'
|
||||
)
|
||||
vi.mocked(useReleaseStore).mockReturnValue(mockReleaseStore as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('visibility', () => {
|
||||
it('should not show when shouldShowPopup is false', () => {
|
||||
mockReleaseStore.shouldShowPopup = false
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show when shouldShowPopup is true and not dismissed', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: 'New features added',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
|
||||
expect(wrapper.find('.whats-new-popup').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide when dismissed locally', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: 'New features added',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Initially visible
|
||||
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
|
||||
|
||||
// Click close button
|
||||
await wrapper.find('.close-button').trigger('click')
|
||||
|
||||
// Should be hidden
|
||||
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('content rendering', () => {
|
||||
it('should render release content using renderMarkdownToHtml', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: '# Release Notes\n\nNew features',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Check that the content is rendered (renderMarkdownToHtml is mocked to return processed content)
|
||||
expect(wrapper.find('.content-text').exists()).toBe(true)
|
||||
const contentHtml = wrapper.find('.content-text').html()
|
||||
expect(contentHtml).toContain('<p># Release Notes')
|
||||
})
|
||||
|
||||
it('should handle missing release content', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: '',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('.content-text').html()).toContain(
|
||||
'whatsNewPopup.noReleaseNotes'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle markdown parsing errors gracefully', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: 'Content with\nnewlines',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Should show content even without markdown processing
|
||||
expect(wrapper.find('.content-text').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('changelog URL generation', () => {
|
||||
it('should generate English changelog URL with version anchor', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0-beta.1',
|
||||
attention: 'medium',
|
||||
content: 'Release content',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const learnMoreLink = wrapper.find('.learn-more-link')
|
||||
|
||||
// formatVersionAnchor replaces dots with dashes: 1.24.0-beta.1 -> v1-24-0-beta-1
|
||||
expect(learnMoreLink.attributes('href')).toBe(
|
||||
'https://docs.comfy.org/changelog#v1-24-0-beta-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('should generate Chinese changelog URL when locale is zh', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: 'Release content',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper({
|
||||
global: {
|
||||
mocks: {
|
||||
$t: vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'g.close': 'Close',
|
||||
'whatsNewPopup.noReleaseNotes': 'No release notes available',
|
||||
'whatsNewPopup.learnMore': 'Learn More'
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
},
|
||||
provide: {
|
||||
// Mock vue-i18n locale as Chinese
|
||||
locale: { value: 'zh' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Since the locale mocking doesn't work well in tests, just check the English URL for now
|
||||
// In a real component test with proper i18n setup, this would show the Chinese URL
|
||||
const learnMoreLink = wrapper.find('.learn-more-link')
|
||||
expect(learnMoreLink.attributes('href')).toBe(
|
||||
'https://docs.comfy.org/changelog#v1-24-0'
|
||||
)
|
||||
})
|
||||
|
||||
it('should generate base changelog URL when no version available', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '',
|
||||
attention: 'medium',
|
||||
content: 'Release content',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const learnMoreLink = wrapper.find('.learn-more-link')
|
||||
|
||||
expect(learnMoreLink.attributes('href')).toBe(
|
||||
'https://docs.comfy.org/changelog'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('popup dismissal', () => {
|
||||
it('should call handleWhatsNewSeen and emit event when closed', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: 'Release content',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Click close button
|
||||
await wrapper.find('.close-button').trigger('click')
|
||||
|
||||
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
|
||||
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
|
||||
expect(wrapper.emitted('whats-new-dismissed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should close when learn more link is clicked', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: 'Release content',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Click learn more link
|
||||
await wrapper.find('.learn-more-link').trigger('click')
|
||||
|
||||
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
|
||||
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle cases where no release is available during close', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = null
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Try to close
|
||||
await wrapper.find('.close-button').trigger('click')
|
||||
|
||||
expect(mockReleaseStore.handleWhatsNewSeen).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exposed methods', () => {
|
||||
it('should expose show and hide methods', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.vm.show).toBeDefined()
|
||||
expect(wrapper.vm.hide).toBeDefined()
|
||||
expect(typeof wrapper.vm.show).toBe('function')
|
||||
expect(typeof wrapper.vm.hide).toBe('function')
|
||||
})
|
||||
|
||||
it('should show popup when show method is called', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Initially hide it
|
||||
wrapper.vm.hide()
|
||||
await nextTick()
|
||||
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
|
||||
|
||||
// Show it
|
||||
wrapper.vm.show()
|
||||
await nextTick()
|
||||
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide popup when hide method is called', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Initially visible
|
||||
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
|
||||
|
||||
// Hide it
|
||||
wrapper.vm.hide()
|
||||
await nextTick()
|
||||
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should fetch releases on mount if not already loaded', async () => {
|
||||
mockReleaseStore.releases = []
|
||||
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
|
||||
|
||||
createWrapper()
|
||||
|
||||
// Wait for onMounted
|
||||
await nextTick()
|
||||
|
||||
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not fetch releases if already loaded', async () => {
|
||||
mockReleaseStore.releases = [
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium' as const,
|
||||
content: 'Content',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
]
|
||||
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
|
||||
|
||||
createWrapper()
|
||||
|
||||
// Wait for onMounted
|
||||
await nextTick()
|
||||
|
||||
expect(mockReleaseStore.fetchReleases).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have proper aria-label for close button', () => {
|
||||
const mockT = vi.fn((key) => (key === 'g.close' ? 'Close' : key))
|
||||
vi.doMock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
locale: { value: 'en' },
|
||||
t: mockT
|
||||
}))
|
||||
}))
|
||||
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: 'Content',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('.close-button').attributes('aria-label')).toBe(
|
||||
'Close'
|
||||
)
|
||||
})
|
||||
|
||||
it('should have proper link attributes for external changelog', () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
project: 'comfyui_frontend',
|
||||
version: '1.24.0',
|
||||
attention: 'medium',
|
||||
content: 'Content',
|
||||
published_at: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const learnMoreLink = wrapper.find('.learn-more-link')
|
||||
|
||||
expect(learnMoreLink.attributes('target')).toBe('_blank')
|
||||
expect(learnMoreLink.attributes('rel')).toBe('noopener,noreferrer')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -577,32 +577,32 @@ describe('useNodePricing', () => {
|
||||
{
|
||||
rendering_speed: 'Quality',
|
||||
character_image: false,
|
||||
expected: '$0.13/Run'
|
||||
expected: '$0.09/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Quality',
|
||||
character_image: true,
|
||||
expected: '$0.29/Run'
|
||||
expected: '$0.20/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Default',
|
||||
character_image: false,
|
||||
expected: '$0.09/Run'
|
||||
expected: '$0.06/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Default',
|
||||
character_image: true,
|
||||
expected: '$0.21/Run'
|
||||
expected: '$0.15/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Turbo',
|
||||
character_image: false,
|
||||
expected: '$0.04/Run'
|
||||
expected: '$0.03/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Turbo',
|
||||
character_image: true,
|
||||
expected: '$0.14/Run'
|
||||
expected: '$0.10/Run'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -623,7 +623,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)'
|
||||
'$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -635,7 +635,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.39/Run') // 0.09 * 3 * 1.43
|
||||
expect(price).toBe('$0.27/Run') // 0.09 * 3
|
||||
})
|
||||
|
||||
it('should multiply price by num_images for Turbo rendering speed', () => {
|
||||
@@ -646,7 +646,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.21/Run') // 0.03 * 5 * 1.43
|
||||
expect(price).toBe('$0.15/Run') // 0.03 * 5
|
||||
})
|
||||
})
|
||||
|
||||
@@ -770,7 +770,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$3.13/Run')
|
||||
expect(price).toBe('$2.19/Run')
|
||||
})
|
||||
|
||||
it('should return $6.37 for ray-2 4K 5s', () => {
|
||||
@@ -782,7 +782,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$9.11/Run')
|
||||
expect(price).toBe('$6.37/Run')
|
||||
})
|
||||
|
||||
it('should return $0.35 for ray-1-6 model', () => {
|
||||
@@ -794,7 +794,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.50/Run')
|
||||
expect(price).toBe('$0.35/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
@@ -803,7 +803,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.20-16.40/Run (varies with model, resolution & duration)'
|
||||
'$0.14-11.47/Run (varies with model, resolution & duration)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1192,7 +1192,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.26/Run') // 0.06 * 3 * 1.43
|
||||
expect(price).toBe('$0.18/Run') // 0.06 * 3
|
||||
})
|
||||
|
||||
it('should calculate dynamic pricing for IdeogramV2 based on num_images value', () => {
|
||||
@@ -1202,7 +1202,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.46/Run') // 0.08 * 4 * 1.43
|
||||
expect(price).toBe('$0.32/Run') // 0.08 * 4
|
||||
})
|
||||
|
||||
it('should fall back to static display when num_images widget is missing for IdeogramV1', () => {
|
||||
@@ -1210,7 +1210,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('IdeogramV1', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.03-0.09 x num_images/Run')
|
||||
expect(price).toBe('$0.02-0.06 x num_images/Run')
|
||||
})
|
||||
|
||||
it('should fall back to static display when num_images widget is missing for IdeogramV2', () => {
|
||||
@@ -1218,7 +1218,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('IdeogramV2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.07-0.11 x num_images/Run')
|
||||
expect(price).toBe('$0.05-0.08 x num_images/Run')
|
||||
})
|
||||
|
||||
it('should handle edge case when num_images value is 1 for IdeogramV1', () => {
|
||||
@@ -1228,7 +1228,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.09/Run') // 0.06 * 1 * 1.43 (turbo=false by default)
|
||||
expect(price).toBe('$0.06/Run') // 0.06 * 1 (turbo=false by default)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1435,7 +1435,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('RunwayTextToImageNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.11/Run')
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => {
|
||||
@@ -1445,7 +1445,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.71/Run') // 0.05 * 10 * 1.43
|
||||
expect(price).toBe('$0.50/Run') // 0.05 * 10
|
||||
})
|
||||
|
||||
it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => {
|
||||
@@ -1453,7 +1453,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('RunwayImageToVideoNodeGen3a', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.0715/second')
|
||||
expect(price).toBe('$0.05/second')
|
||||
})
|
||||
|
||||
it('should handle zero duration for RunwayImageToVideoNodeGen3a', () => {
|
||||
@@ -1473,7 +1473,7 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.36/Run') // Falls back to 5 seconds: 0.05 * 5 * 1.43
|
||||
expect(price).toBe('$0.25/Run') // Falls back to 5 seconds: 0.05 * 5
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1810,8 +1810,8 @@ describe('useNodePricing', () => {
|
||||
// Test edge cases
|
||||
const testCases = [
|
||||
{ duration: 0, expected: '$0.00/Run' }, // Now correctly handles 0 duration
|
||||
{ duration: 1, expected: '$0.07/Run' },
|
||||
{ duration: 30, expected: '$2.15/Run' }
|
||||
{ duration: 1, expected: '$0.05/Run' },
|
||||
{ duration: 30, expected: '$1.50/Run' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ duration, expected }) => {
|
||||
@@ -1828,7 +1828,7 @@ describe('useNodePricing', () => {
|
||||
{ name: 'duration', value: 'invalid-string' }
|
||||
])
|
||||
// When Number('invalid-string') returns NaN, it falls back to 5 seconds
|
||||
expect(getNodeDisplayPrice(node)).toBe('$0.36/Run')
|
||||
expect(getNodeDisplayPrice(node)).toBe('$0.25/Run')
|
||||
})
|
||||
|
||||
it('should handle missing duration widget gracefully', () => {
|
||||
@@ -1841,7 +1841,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
nodes.forEach((nodeType) => {
|
||||
const node = createMockNode(nodeType, [])
|
||||
expect(getNodeDisplayPrice(node)).toBe('$0.0715/second')
|
||||
expect(getNodeDisplayPrice(node)).toBe('$0.05/second')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
|
||||
// Mock the environment utilities
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
@@ -9,22 +6,27 @@ vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock vue-i18n
|
||||
const mockLocale = ref('en')
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
locale: mockLocale
|
||||
}))
|
||||
// Provide a minimal i18n instance for the composable
|
||||
const i18n = vi.hoisted(() => ({
|
||||
global: {
|
||||
locale: {
|
||||
value: 'en'
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n
|
||||
}))
|
||||
|
||||
// Import after mocking to get the mocked versions
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
|
||||
describe('useExternalLink', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset to default state
|
||||
mockLocale.value = 'en'
|
||||
i18n.global.locale.value = 'en'
|
||||
vi.mocked(isElectron).mockReturnValue(false)
|
||||
})
|
||||
|
||||
@@ -53,7 +55,7 @@ describe('useExternalLink', () => {
|
||||
|
||||
describe('buildDocsUrl', () => {
|
||||
it('should build basic docs URL without locale', () => {
|
||||
mockLocale.value = 'en'
|
||||
i18n.global.locale.value = 'en'
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
const url = buildDocsUrl('/changelog')
|
||||
@@ -61,7 +63,7 @@ describe('useExternalLink', () => {
|
||||
})
|
||||
|
||||
it('should build docs URL with Chinese (zh) locale when requested', () => {
|
||||
mockLocale.value = 'zh'
|
||||
i18n.global.locale.value = 'zh'
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
const url = buildDocsUrl('/changelog', { includeLocale: true })
|
||||
@@ -69,7 +71,7 @@ describe('useExternalLink', () => {
|
||||
})
|
||||
|
||||
it('should build docs URL with Chinese (zh-TW) locale when requested', () => {
|
||||
mockLocale.value = 'zh-TW'
|
||||
i18n.global.locale.value = 'zh-TW'
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
const url = buildDocsUrl('/changelog', { includeLocale: true })
|
||||
@@ -77,7 +79,7 @@ describe('useExternalLink', () => {
|
||||
})
|
||||
|
||||
it('should not include locale for English when requested', () => {
|
||||
mockLocale.value = 'en'
|
||||
i18n.global.locale.value = 'en'
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
const url = buildDocsUrl('/changelog', { includeLocale: true })
|
||||
@@ -92,7 +94,7 @@ describe('useExternalLink', () => {
|
||||
})
|
||||
|
||||
it('should add platform suffix when requested', () => {
|
||||
mockLocale.value = 'en'
|
||||
i18n.global.locale.value = 'en'
|
||||
vi.mocked(isElectron).mockReturnValue(true)
|
||||
vi.mocked(electronAPI).mockReturnValue({
|
||||
getPlatform: () => 'darwin'
|
||||
@@ -104,7 +106,7 @@ describe('useExternalLink', () => {
|
||||
})
|
||||
|
||||
it('should add platform suffix with trailing slash', () => {
|
||||
mockLocale.value = 'en'
|
||||
i18n.global.locale.value = 'en'
|
||||
vi.mocked(isElectron).mockReturnValue(true)
|
||||
vi.mocked(electronAPI).mockReturnValue({
|
||||
getPlatform: () => 'win32'
|
||||
@@ -116,7 +118,7 @@ describe('useExternalLink', () => {
|
||||
})
|
||||
|
||||
it('should combine locale and platform', () => {
|
||||
mockLocale.value = 'zh'
|
||||
i18n.global.locale.value = 'zh'
|
||||
vi.mocked(isElectron).mockReturnValue(true)
|
||||
vi.mocked(electronAPI).mockReturnValue({
|
||||
getPlatform: () => 'darwin'
|
||||
@@ -133,7 +135,7 @@ describe('useExternalLink', () => {
|
||||
})
|
||||
|
||||
it('should not add platform when not desktop', () => {
|
||||
mockLocale.value = 'en'
|
||||
i18n.global.locale.value = 'en'
|
||||
vi.mocked(isElectron).mockReturnValue(false)
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
|
||||
|
||||
const mockLoadStripeScript = vi.fn()
|
||||
let currentConfig = {
|
||||
publishableKey: 'pk_test_123',
|
||||
pricingTableId: 'prctbl_123'
|
||||
}
|
||||
let hasConfig = true
|
||||
|
||||
vi.mock('@/config/stripePricingTableConfig', () => ({
|
||||
getStripePricingTableConfig: () => currentConfig,
|
||||
hasStripePricingTableConfig: () => hasConfig
|
||||
}))
|
||||
|
||||
const mockIsLoaded = ref(false)
|
||||
const mockIsLoading = ref(false)
|
||||
const mockError = ref(null)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useStripePricingTableLoader',
|
||||
() => ({
|
||||
useStripePricingTableLoader: () => ({
|
||||
loadScript: mockLoadStripeScript,
|
||||
isLoaded: mockIsLoaded,
|
||||
isLoading: mockIsLoading,
|
||||
error: mockError
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(StripePricingTable, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe('StripePricingTable', () => {
|
||||
beforeEach(() => {
|
||||
currentConfig = {
|
||||
publishableKey: 'pk_test_123',
|
||||
pricingTableId: 'prctbl_123'
|
||||
}
|
||||
hasConfig = true
|
||||
mockLoadStripeScript.mockReset().mockResolvedValue(undefined)
|
||||
mockIsLoaded.value = false
|
||||
mockIsLoading.value = false
|
||||
mockError.value = null
|
||||
})
|
||||
|
||||
it('renders the Stripe pricing table when config is available', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockLoadStripeScript).toHaveBeenCalled()
|
||||
|
||||
const stripePricingTable = wrapper.find('stripe-pricing-table')
|
||||
expect(stripePricingTable.exists()).toBe(true)
|
||||
expect(stripePricingTable.attributes('publishable-key')).toBe('pk_test_123')
|
||||
expect(stripePricingTable.attributes('pricing-table-id')).toBe('prctbl_123')
|
||||
})
|
||||
|
||||
it('shows missing config message when credentials are absent', () => {
|
||||
hasConfig = false
|
||||
currentConfig = { publishableKey: '', pricingTableId: '' }
|
||||
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="stripe-table-missing-config"]').exists()
|
||||
).toBe(true)
|
||||
expect(mockLoadStripeScript).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows loading indicator when script is loading', async () => {
|
||||
// Mock loadScript to never resolve, simulating loading state
|
||||
mockLoadStripeScript.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="stripe-table-loading"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows error indicator when script fails to load', async () => {
|
||||
// Mock loadScript to reject, simulating error state
|
||||
mockLoadStripeScript.mockRejectedValue(new Error('Script failed to load'))
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="stripe-table-error"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,35 +1,18 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue'
|
||||
|
||||
// Mock state refs that can be modified between tests
|
||||
const mockIsActiveSubscription = ref(false)
|
||||
const mockIsCancelled = ref(false)
|
||||
const mockSubscriptionTier = ref<
|
||||
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
|
||||
>('CREATOR')
|
||||
|
||||
const TIER_TO_NAME: Record<string, string> = {
|
||||
STANDARD: 'Standard',
|
||||
CREATOR: 'Creator',
|
||||
PRO: 'Pro',
|
||||
FOUNDERS_EDITION: "Founder's Edition"
|
||||
}
|
||||
|
||||
// Mock composables - using computed to match composable return types
|
||||
// Mock composables
|
||||
const mockSubscriptionData = {
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
isCancelled: computed(() => mockIsCancelled.value),
|
||||
formattedRenewalDate: computed(() => '2024-12-31'),
|
||||
formattedEndDate: computed(() => '2024-12-31'),
|
||||
subscriptionTier: computed(() => mockSubscriptionTier.value),
|
||||
subscriptionTierName: computed(() =>
|
||||
mockSubscriptionTier.value ? TIER_TO_NAME[mockSubscriptionTier.value] : ''
|
||||
),
|
||||
isActiveSubscription: false,
|
||||
isCancelled: false,
|
||||
formattedRenewalDate: '2024-12-31',
|
||||
formattedEndDate: '2024-12-31',
|
||||
formattedMonthlyPrice: '$9.99',
|
||||
manageSubscription: vi.fn(),
|
||||
handleInvoiceHistory: vi.fn()
|
||||
}
|
||||
|
||||
@@ -42,6 +25,7 @@ const mockCreditsData = {
|
||||
|
||||
const mockActionsData = {
|
||||
isLoadingSupport: false,
|
||||
refreshTooltip: 'Refreshes on 2024-12-31',
|
||||
handleAddApiCredits: vi.fn(),
|
||||
handleMessageSupport: vi.fn(),
|
||||
handleRefresh: vi.fn(),
|
||||
@@ -66,15 +50,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
show: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Create i18n instance for testing
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -83,15 +58,12 @@ const i18n = createI18n({
|
||||
en: {
|
||||
subscription: {
|
||||
title: 'Subscription',
|
||||
titleUnsubscribed: 'Subscribe',
|
||||
perMonth: '/ month',
|
||||
subscribeNow: 'Subscribe Now',
|
||||
manageSubscription: 'Manage Subscription',
|
||||
partnerNodesBalance: 'Partner Nodes Balance',
|
||||
partnerNodesDescription: 'Credits for partner nodes',
|
||||
totalCredits: 'Total Credits',
|
||||
creditsRemainingThisMonth: 'Credits remaining this month',
|
||||
creditsYouveAdded: "Credits you've added",
|
||||
monthlyBonusDescription: 'Monthly bonus',
|
||||
prepaidDescription: 'Prepaid credits',
|
||||
monthlyCreditsRollover: 'Monthly credits rollover info',
|
||||
@@ -99,67 +71,11 @@ const i18n = createI18n({
|
||||
viewUsageHistory: 'View Usage History',
|
||||
addCredits: 'Add Credits',
|
||||
yourPlanIncludes: 'Your plan includes',
|
||||
viewMoreDetailsPlans: 'View more details about plans & pricing',
|
||||
learnMore: 'Learn More',
|
||||
messageSupport: 'Message Support',
|
||||
invoiceHistory: 'Invoice History',
|
||||
partnerNodesCredits: 'Partner nodes pricing',
|
||||
renewsDate: 'Renews {date}',
|
||||
expiresDate: 'Expires {date}',
|
||||
tiers: {
|
||||
founder: {
|
||||
name: "Founder's Edition",
|
||||
price: '20.00',
|
||||
benefits: {
|
||||
monthlyCredits: '5,460',
|
||||
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'
|
||||
}
|
||||
},
|
||||
standard: {
|
||||
name: 'Standard',
|
||||
price: '20.00',
|
||||
benefits: {
|
||||
monthlyCredits: '4,200',
|
||||
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'
|
||||
}
|
||||
},
|
||||
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',
|
||||
monthlyCreditsLabel: 'monthly credits',
|
||||
maxDuration: '1 hr',
|
||||
maxDurationLabel: 'max duration of each workflow run',
|
||||
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
|
||||
addCreditsLabel: 'Add more credits whenever',
|
||||
customLoRAsLabel: 'Import your own LoRAs'
|
||||
}
|
||||
}
|
||||
}
|
||||
expiresDate: 'Expires {date}'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,22 +116,18 @@ function createWrapper(overrides = {}) {
|
||||
describe('SubscriptionPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mock state
|
||||
mockIsActiveSubscription.value = false
|
||||
mockIsCancelled.value = false
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
})
|
||||
|
||||
describe('subscription state functionality', () => {
|
||||
it('shows correct UI for active subscription', () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionData.isActiveSubscription = true
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Manage Subscription')
|
||||
expect(wrapper.text()).toContain('Add Credits')
|
||||
})
|
||||
|
||||
it('shows correct UI for inactive subscription', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
mockSubscriptionData.isActiveSubscription = false
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
|
||||
true
|
||||
@@ -225,32 +137,18 @@ describe('SubscriptionPanel', () => {
|
||||
})
|
||||
|
||||
it('shows renewal date for active non-cancelled subscription', () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsCancelled.value = false
|
||||
mockSubscriptionData.isActiveSubscription = true
|
||||
mockSubscriptionData.isCancelled = false
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Renews 2024-12-31')
|
||||
})
|
||||
|
||||
it('shows expiry date for cancelled subscription', () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsCancelled.value = true
|
||||
mockSubscriptionData.isActiveSubscription = true
|
||||
mockSubscriptionData.isCancelled = true
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Expires 2024-12-31')
|
||||
})
|
||||
|
||||
it('displays FOUNDERS_EDITION tier correctly', () => {
|
||||
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain("Founder's Edition")
|
||||
expect(wrapper.text()).toContain('5,460')
|
||||
})
|
||||
|
||||
it('displays CREATOR tier correctly', () => {
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Creator')
|
||||
expect(wrapper.text()).toContain('7,400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('credit display functionality', () => {
|
||||
|
||||
@@ -7,6 +7,23 @@ const mockFetchBalance = vi.fn()
|
||||
const mockFetchStatus = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
const mockExecute = vi.fn()
|
||||
const mockT = vi.fn((key: string, values?: any) => {
|
||||
if (key === 'subscription.nextBillingCycle') return 'next billing cycle'
|
||||
if (key === 'subscription.refreshesOn') {
|
||||
return `Refreshes to $${values?.monthlyCreditBonusUsd} on ${values?.date}`
|
||||
}
|
||||
return key
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: mockT
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: () => ({
|
||||
@@ -14,9 +31,12 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockFormattedRenewalDate = { value: '2024-12-31' }
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
fetchStatus: mockFetchStatus
|
||||
fetchStatus: mockFetchStatus,
|
||||
formattedRenewalDate: mockFormattedRenewalDate
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -42,6 +62,23 @@ Object.defineProperty(window, 'open', {
|
||||
describe('useSubscriptionActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFormattedRenewalDate.value = '2024-12-31'
|
||||
})
|
||||
|
||||
describe('refreshTooltip', () => {
|
||||
it('should format tooltip with renewal date', () => {
|
||||
const { refreshTooltip } = useSubscriptionActions()
|
||||
expect(refreshTooltip.value).toBe('Refreshes to $10 on 2024-12-31')
|
||||
})
|
||||
|
||||
it('should use fallback text when no renewal date', () => {
|
||||
mockFormattedRenewalDate.value = ''
|
||||
const { refreshTooltip } = useSubscriptionActions()
|
||||
expect(refreshTooltip.value).toBe(
|
||||
'Refreshes to $10 on next billing cycle'
|
||||
)
|
||||
expect(mockT).toHaveBeenCalledWith('subscription.nextBillingCycle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAddApiCredits', () => {
|
||||
|
||||
@@ -152,28 +152,10 @@ describe('useSubscription', () => {
|
||||
expect(formattedRenewalDate.value).toBe('')
|
||||
})
|
||||
|
||||
it('should return subscription tier from status', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
subscription_tier: 'CREATOR',
|
||||
renewal_date: '2025-11-16T12:00:00Z'
|
||||
})
|
||||
} as Response)
|
||||
it('should format monthly price correctly', () => {
|
||||
const { formattedMonthlyPrice } = useSubscription()
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { subscriptionTier, fetchStatus } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
expect(subscriptionTier.value).toBe('CREATOR')
|
||||
})
|
||||
|
||||
it('should return null when subscription tier is not available', () => {
|
||||
const { subscriptionTier } = useSubscription()
|
||||
|
||||
expect(subscriptionTier.value).toBeNull()
|
||||
expect(formattedMonthlyPrice.value).toBe('$20')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -187,8 +187,7 @@ describe('useTemplateUrlLoader', () => {
|
||||
'flux_simple',
|
||||
'flux-kontext-dev',
|
||||
'template123',
|
||||
'My_Template-2',
|
||||
'templates-1_click_multiple_scene_angles-v1.0' // template with version number containing dot
|
||||
'My_Template-2'
|
||||
]
|
||||
|
||||
for (const template of validTemplates) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user