mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-07 04:50:08 +00:00
Compare commits
31 Commits
| 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 |
@@ -42,3 +42,7 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
# SENTRY_PROJECT=cloud-frontend-staging
|
||||
|
||||
# Stripe pricing table configuration (used by feature-flagged subscription tiers UI)
|
||||
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123
|
||||
# VITE_STRIPE_PRICING_TABLE_ID=prctbl_123
|
||||
|
||||
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')
|
||||
)
|
||||
])
|
||||
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -13,6 +13,8 @@ interface Window {
|
||||
max_upload_size?: number
|
||||
comfy_api_base_url?: string
|
||||
comfy_platform_base_url?: string
|
||||
stripe_publishable_key?: string
|
||||
stripe_pricing_table_id?: string
|
||||
firebase_config?: {
|
||||
apiKey: string
|
||||
authDomain: string
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
125
src/base/credits/comfyCredits.ts
Normal file
125
src/base/credits/comfyCredits.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
const DEFAULT_NUMBER_FORMAT: Intl.NumberFormatOptions = {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
|
||||
const formatNumber = ({
|
||||
value,
|
||||
locale,
|
||||
options
|
||||
}: {
|
||||
value: number
|
||||
locale?: string
|
||||
options?: Intl.NumberFormatOptions
|
||||
}): string => {
|
||||
const merged: Intl.NumberFormatOptions = {
|
||||
...DEFAULT_NUMBER_FORMAT,
|
||||
...options
|
||||
}
|
||||
|
||||
if (
|
||||
typeof merged.maximumFractionDigits === 'number' &&
|
||||
typeof merged.minimumFractionDigits === 'number' &&
|
||||
merged.maximumFractionDigits < merged.minimumFractionDigits
|
||||
) {
|
||||
merged.minimumFractionDigits = merged.maximumFractionDigits
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, merged).format(value)
|
||||
}
|
||||
|
||||
export const CREDITS_PER_USD = 211
|
||||
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
|
||||
|
||||
export const usdToCents = (usd: number): number => Math.round(usd * 100)
|
||||
|
||||
export const centsToCredits = (cents: number): number =>
|
||||
Math.round(cents * COMFY_CREDIT_RATE_CENTS)
|
||||
|
||||
export const creditsToCents = (credits: number): number =>
|
||||
Math.round(credits / COMFY_CREDIT_RATE_CENTS)
|
||||
|
||||
export const usdToCredits = (usd: number): number =>
|
||||
Math.round(usd * CREDITS_PER_USD)
|
||||
|
||||
export const creditsToUsd = (credits: number): number =>
|
||||
Math.round((credits / CREDITS_PER_USD) * 100) / 100
|
||||
|
||||
export type FormatOptions = {
|
||||
value: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export type FormatFromCentsOptions = {
|
||||
cents: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export type FormatFromUsdOptions = {
|
||||
usd: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export const formatCredits = ({
|
||||
value,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatOptions): string =>
|
||||
formatNumber({ value, locale, options: numberOptions })
|
||||
|
||||
export const formatCreditsFromCents = ({
|
||||
cents,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatFromCentsOptions): string =>
|
||||
formatCredits({
|
||||
value: centsToCredits(cents),
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
export const formatCreditsFromUsd = ({
|
||||
usd,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatFromUsdOptions): string =>
|
||||
formatCredits({
|
||||
value: usdToCredits(usd),
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
export const formatUsd = ({
|
||||
value,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatOptions): string =>
|
||||
formatNumber({
|
||||
value,
|
||||
locale,
|
||||
options: numberOptions
|
||||
})
|
||||
|
||||
export const formatUsdFromCents = ({
|
||||
cents,
|
||||
locale,
|
||||
numberOptions
|
||||
}: FormatFromCentsOptions): string =>
|
||||
formatUsd({
|
||||
value: cents / 100,
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
/**
|
||||
* Clamps a USD value to the allowed range for credit purchases
|
||||
* @param value - The USD amount to clamp
|
||||
* @returns The clamped value between $1 and $1000, or 0 if NaN
|
||||
*/
|
||||
export const clampUsd = (value: number): number => {
|
||||
if (Number.isNaN(value)) return 0
|
||||
return Math.min(1000, Math.max(1, value))
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -8,12 +8,24 @@
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<Tag
|
||||
v-if="!showCreditsOnly"
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="p-1 text-amber-400"
|
||||
/>
|
||||
<div :class="textClass">{{ formattedBalance }}</div>
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
:class="
|
||||
flags.subscriptionTiersEnabled
|
||||
? 'icon-[lucide--component]'
|
||||
: 'pi pi-dollar'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
{{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,19 +33,39 @@
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
const { textClass } = defineProps<{
|
||||
const { textClass, showCreditsOnly } = defineProps<{
|
||||
textClass?: string
|
||||
showCreditsOnly?: boolean
|
||||
}>()
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
if (!authStore.balance) return '0.00'
|
||||
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
|
||||
// Backend returns cents despite the *_micros naming convention.
|
||||
const cents = authStore.balance?.amount_micros ?? 0
|
||||
const amount = formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value
|
||||
})
|
||||
return `${amount} ${t('credits.credits')}`
|
||||
})
|
||||
|
||||
const formattedCreditsOnly = computed(() => {
|
||||
// Backend returns cents despite the *_micros naming convention.
|
||||
const cents = authStore.balance?.amount_micros ?? 0
|
||||
const amount = formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value
|
||||
})
|
||||
return amount
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
<template>
|
||||
<div class="flex w-96 flex-col gap-10 p-2">
|
||||
<!-- New Credits Design (default) -->
|
||||
<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-white m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h1>
|
||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0 w-96">
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0">
|
||||
{{ $t('credits.topUp.creditsDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Balance Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<UserCredit text-class="text-3xl font-bold" show-credits-only />
|
||||
<span class="text-sm text-muted-foreground">{{
|
||||
$t('credits.creditsAvailable')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Options Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.howManyCredits') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-2">
|
||||
<CreditTopUpOption
|
||||
v-for="option in creditOptions"
|
||||
:key="option.credits"
|
||||
:credits="option.credits"
|
||||
:description="option.description"
|
||||
:selected="selectedCredits === option.credits"
|
||||
@select="selectedCredits = option.credits"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground w-96">
|
||||
{{ $t('credits.topUp.templateNote') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buy Button -->
|
||||
<Button
|
||||
:disabled="!selectedCredits || loading"
|
||||
:loading="loading"
|
||||
severity="primary"
|
||||
:label="$t('credits.topUp.buy')"
|
||||
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
|
||||
:pt="{ label: { class: 'text-white' } }"
|
||||
@click="handleBuy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Legacy Design -->
|
||||
<div v-else class="flex w-96 flex-col gap-10 p-2">
|
||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
|
||||
<h1 class="my-0 text-2xl leading-normal font-medium">
|
||||
{{ $t('credits.topUp.insufficientTitle') }}
|
||||
@@ -34,14 +103,14 @@
|
||||
>{{ $t('credits.topUp.quickPurchase') }}:</span
|
||||
>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
||||
<CreditTopUpOption
|
||||
<LegacyCreditTopUpOption
|
||||
v-for="amount in amountOptions"
|
||||
:key="amount"
|
||||
:amount="amount"
|
||||
:preselected="amount === preselectedAmountOption"
|
||||
/>
|
||||
|
||||
<CreditTopUpOption :amount="100" :preselected="false" editable />
|
||||
<LegacyCreditTopUpOption :amount="100" :preselected="false" editable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,11 +118,28 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
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'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
|
||||
import LegacyCreditTopUpOption from './credit/LegacyCreditTopUpOption.vue'
|
||||
|
||||
interface CreditOption {
|
||||
credits: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const {
|
||||
isInsufficientCredits = false,
|
||||
@@ -65,7 +151,74 @@ const {
|
||||
preselectedAmountOption?: number
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
// Use feature flag to determine design - defaults to true (new design)
|
||||
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
|
||||
const selectedCredits = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const creditOptions: CreditOption[] = [
|
||||
{
|
||||
credits: 1055, // $5.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 41 })
|
||||
},
|
||||
{
|
||||
credits: 2110, // $10.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 82 })
|
||||
},
|
||||
{
|
||||
credits: 4220, // $20.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 184 })
|
||||
},
|
||||
{
|
||||
credits: 10550, // $50.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 412 })
|
||||
}
|
||||
]
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!selectedCredits.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
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)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('credits.topUp.unknownError')
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeeDetails = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
|
||||
@@ -1,81 +1,45 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="p-1 text-amber-400"
|
||||
/>
|
||||
<InputNumber
|
||||
v-if="editable"
|
||||
v-model="customAmount"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
show-buttons
|
||||
:allow-empty="false"
|
||||
:highlight-on-focus="true"
|
||||
pt:pc-input-text:root="w-24"
|
||||
@blur="(e: InputNumberBlurEvent) => (customAmount = Number(e.value))"
|
||||
@input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
|
||||
/>
|
||||
<span v-else class="text-xl">{{ amount }}</span>
|
||||
<div
|
||||
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
|
||||
:class="[
|
||||
selected
|
||||
? 'bg-secondary-background border-2 border-border-default'
|
||||
: 'bg-component-node-disabled hover:bg-secondary-background border-2 border-transparent'
|
||||
]"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<span class="text-base font-bold text-white">
|
||||
{{ formattedCredits }}
|
||||
</span>
|
||||
<span class="text-sm font-normal text-white">
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressSpinner v-if="loading" class="h-8 w-8" />
|
||||
<Button
|
||||
v-else
|
||||
:severity="preselected ? 'primary' : 'secondary'"
|
||||
:outlined="!preselected"
|
||||
:label="$t('credits.topUp.buyNow')"
|
||||
@click="handleBuyNow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import type {
|
||||
InputNumberBlurEvent,
|
||||
InputNumberInputEvent
|
||||
} from 'primevue/inputnumber'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tag from 'primevue/tag'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const {
|
||||
amount,
|
||||
preselected,
|
||||
editable = false
|
||||
} = defineProps<{
|
||||
amount: number
|
||||
preselected: boolean
|
||||
editable?: boolean
|
||||
const { credits, description, selected } = defineProps<{
|
||||
credits: number
|
||||
description: string
|
||||
selected: boolean
|
||||
}>()
|
||||
|
||||
const customAmount = ref(amount)
|
||||
const didClickBuyNow = ref(false)
|
||||
const loading = ref(false)
|
||||
defineEmits<{
|
||||
select: []
|
||||
}>()
|
||||
|
||||
const handleBuyNow = async () => {
|
||||
const creditAmount = editable ? customAmount.value : amount
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
|
||||
const { locale } = useI18n()
|
||||
|
||||
loading.value = true
|
||||
await authActions.purchaseCredits(creditAmount)
|
||||
loading.value = false
|
||||
didClickBuyNow.value = true
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (didClickBuyNow.value) {
|
||||
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
const formattedCredits = computed(() => {
|
||||
return formatCredits({
|
||||
value: credits,
|
||||
locale: locale.value,
|
||||
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
119
src/components/dialog/content/credit/LegacyCreditTopUpOption.vue
Normal file
119
src/components/dialog/content/credit/LegacyCreditTopUpOption.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag
|
||||
severity="secondary"
|
||||
icon="pi pi-wallet"
|
||||
rounded
|
||||
class="p-1 text-amber-400"
|
||||
/>
|
||||
<div v-if="editable" class="flex items-center gap-2">
|
||||
<InputNumber
|
||||
v-model="customAmount"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
show-buttons
|
||||
:allow-empty="false"
|
||||
:highlight-on-focus="true"
|
||||
prefix="$"
|
||||
pt:pc-input-text:root="w-28"
|
||||
@blur="
|
||||
(e: InputNumberBlurEvent) =>
|
||||
(customAmount = clampUsd(Number(e.value)))
|
||||
"
|
||||
@input="
|
||||
(e: InputNumberInputEvent) =>
|
||||
(customAmount = clampUsd(Number(e.value)))
|
||||
"
|
||||
/>
|
||||
<span class="text-xs text-muted">{{ formattedCredits }}</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col leading-tight">
|
||||
<span class="text-xl font-semibold">{{ formattedCredits }}</span>
|
||||
<span class="text-xs text-muted">{{ formattedUsd }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ProgressSpinner v-if="loading" class="h-8 w-8" />
|
||||
<Button
|
||||
v-else
|
||||
:severity="preselected ? 'primary' : 'secondary'"
|
||||
:outlined="!preselected"
|
||||
:label="$t('credits.topUp.buyNow')"
|
||||
@click="handleBuyNow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import type {
|
||||
InputNumberBlurEvent,
|
||||
InputNumberInputEvent
|
||||
} from 'primevue/inputnumber'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
clampUsd,
|
||||
formatCreditsFromUsd,
|
||||
formatUsd
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const {
|
||||
amount,
|
||||
preselected,
|
||||
editable = false
|
||||
} = defineProps<{
|
||||
amount: number
|
||||
preselected: boolean
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
const customAmount = ref(amount)
|
||||
const didClickBuyNow = ref(false)
|
||||
const loading = ref(false)
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const displayUsdAmount = computed(() =>
|
||||
editable ? clampUsd(Number(customAmount.value)) : clampUsd(amount)
|
||||
)
|
||||
|
||||
const formattedCredits = computed(
|
||||
() =>
|
||||
`${formatCreditsFromUsd({
|
||||
usd: displayUsdAmount.value,
|
||||
locale: locale.value
|
||||
})} ${t('credits.credits')}`
|
||||
)
|
||||
|
||||
const formattedUsd = computed(
|
||||
() => `$${formatUsd({ value: displayUsdAmount.value, locale: locale.value })}`
|
||||
)
|
||||
|
||||
const handleBuyNow = async () => {
|
||||
const creditAmount = displayUsdAmount.value
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await authActions.purchaseCredits(creditAmount)
|
||||
didClickBuyNow.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (didClickBuyNow.value) {
|
||||
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<TabPanel value="Credits" class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
@@ -1,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 */
|
||||
|
||||
@@ -75,6 +75,7 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -263,11 +264,21 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
const tasks = queueStore.runningTasks
|
||||
await Promise.all(
|
||||
tasks
|
||||
.filter((task) => task.promptId != null)
|
||||
.map((task) => api.interrupt(task.promptId))
|
||||
)
|
||||
const promptIds = tasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
if (!promptIds.length) return
|
||||
|
||||
// Cloud backend supports cancelling specific jobs via /queue delete,
|
||||
// while /interrupt always targets the "first" job. Use the targeted API
|
||||
// on cloud to ensure we cancel the workflow the user clicked.
|
||||
if (isCloud) {
|
||||
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(promptIds.map((id) => api.interrupt(id)))
|
||||
})
|
||||
|
||||
const showClearHistoryDialog = () => {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
@@ -74,7 +74,9 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: 'Bearer mock-token' })
|
||||
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
|
||||
balance: { amount_micros: 100_000 }, // 100,000 cents = ~211,000 credits
|
||||
isFetchingBalance: false
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -107,6 +109,39 @@ vi.mock('@/components/common/UserCredit.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock formatCreditsFromCents
|
||||
vi.mock('@/base/credits/comfyCredits', () => ({
|
||||
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
|
||||
}))
|
||||
|
||||
// Mock useExternalLink
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`)
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock useFeatureFlags
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => ({
|
||||
flags: {
|
||||
subscriptionTiersEnabled: true
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock useTelemetry
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock isCloud
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
default: {
|
||||
name: 'SubscribeButtonMock',
|
||||
@@ -145,27 +180,37 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.text()).toContain('test@example.com')
|
||||
})
|
||||
|
||||
it('renders logout button with correct props', () => {
|
||||
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (last button)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[4]
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
cents: 100_000,
|
||||
locale: 'en',
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
|
||||
// Check that logout button has correct props
|
||||
expect(logoutButton.props('label')).toBe('Log Out')
|
||||
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
|
||||
// Verify the formatted credit string (1000) is rendered in the DOM
|
||||
expect(wrapper.text()).toContain('1000')
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings button is clicked', async () => {
|
||||
it('renders logout menu item with correct text', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the settings button (third button)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const settingsButton = buttons[2]
|
||||
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
|
||||
expect(logoutItem.exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Log Out')
|
||||
})
|
||||
|
||||
// Click the settings button
|
||||
await settingsButton.trigger('click')
|
||||
it('opens user settings and emits close event when settings item is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const settingsItem = wrapper.find('[data-testid="user-settings-menu-item"]')
|
||||
expect(settingsItem.exists()).toBe(true)
|
||||
|
||||
await settingsItem.trigger('click')
|
||||
|
||||
// Verify showSettingsDialog was called with 'user'
|
||||
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
|
||||
@@ -175,15 +220,13 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('calls logout function and emits close event when logout button is clicked', async () => {
|
||||
it('calls logout function and emits close event when logout item is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (last button)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[4]
|
||||
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
|
||||
expect(logoutItem.exists()).toBe(true)
|
||||
|
||||
// Click the logout button
|
||||
await logoutButton.trigger('click')
|
||||
await logoutItem.trigger('click')
|
||||
|
||||
// Verify handleSignOut was called
|
||||
expect(mockHandleSignOut).toHaveBeenCalled()
|
||||
@@ -193,15 +236,15 @@ describe('CurrentUserPopover', () => {
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
|
||||
it('opens API pricing docs and emits close event when partner nodes item is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the Partner Nodes info button (first one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const partnerNodesButton = buttons[0]
|
||||
const partnerNodesItem = wrapper.find(
|
||||
'[data-testid="partner-nodes-menu-item"]'
|
||||
)
|
||||
expect(partnerNodesItem.exists()).toBe(true)
|
||||
|
||||
// Click the Partner Nodes button
|
||||
await partnerNodesButton.trigger('click')
|
||||
await partnerNodesItem.trigger('click')
|
||||
|
||||
// Verify window.open was called with the correct URL
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
@@ -217,11 +260,9 @@ describe('CurrentUserPopover', () => {
|
||||
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the top-up button (second one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const topUpButton = buttons[1]
|
||||
const topUpButton = wrapper.find('[data-testid="add-credits-button"]')
|
||||
expect(topUpButton.exists()).toBe(true)
|
||||
|
||||
// Click the top-up button
|
||||
await topUpButton.trigger('click')
|
||||
|
||||
// Verify showTopUpCreditsDialog was called
|
||||
|
||||
@@ -1,112 +1,141 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div class="current-user-popover w-72">
|
||||
<div
|
||||
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
<div class="p-3">
|
||||
<div class="flex flex-col items-center">
|
||||
<UserAvatar
|
||||
class="mb-3"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{
|
||||
'text-2xl!': !userPhotoUrl
|
||||
}"
|
||||
size="large"
|
||||
/>
|
||||
<div class="flex flex-col items-center px-0 py-3 mb-4">
|
||||
<UserAvatar
|
||||
class="mb-1"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{
|
||||
'text-2xl!': !userPhotoUrl
|
||||
}"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- User Details -->
|
||||
<h3 class="my-0 mb-1 truncate text-lg font-semibold">
|
||||
{{ userDisplayName || $t('g.user') }}
|
||||
</h3>
|
||||
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- User Details -->
|
||||
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
|
||||
{{ userDisplayName || $t('g.user') }}
|
||||
</h3>
|
||||
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isActiveSubscription" class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<UserCredit text-class="text-2xl" />
|
||||
<Button
|
||||
:label="$t('subscription.partnerNodesCredits')"
|
||||
severity="secondary"
|
||||
text
|
||||
size="small"
|
||||
class="pl-6 p-0 h-auto justify-start"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'hover:bg-transparent active:bg-transparent'
|
||||
}
|
||||
}"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
/>
|
||||
</div>
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-sm" />
|
||||
<span class="text-base font-normal text-base-foreground flex-1">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<Button
|
||||
:label="$t('credits.topUp.topUp')"
|
||||
:label="$t('subscription.addCredits')"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubscribeButton
|
||||
v-else
|
||||
class="mx-4"
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="small"
|
||||
variant="gradient"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
|
||||
<Divider class="my-2" />
|
||||
<!-- Credits info row -->
|
||||
<div
|
||||
v-if="flags.subscriptionTiersEnabled && isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-0"
|
||||
>
|
||||
<i
|
||||
v-tooltip="{
|
||||
value: $t('credits.unified.tooltip'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
class="icon-[lucide--circle-help] cursor-help text-xs text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">{{
|
||||
$t('credits.unified.message')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('userSettings.title')"
|
||||
icon="pi pi-cog"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
@click="handleOpenUserSettings"
|
||||
/>
|
||||
<Divider class="my-2 mx-0" />
|
||||
|
||||
<Button
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="justify-start"
|
||||
:label="$t(planSettingsLabel)"
|
||||
icon="pi pi-receipt"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
>
|
||||
<i class="icon-[lucide--tag] text-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
$t('subscription.partnerNodesCredits')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
data-testid="plan-credits-menu-item"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
/>
|
||||
>
|
||||
<i class="icon-[lucide--receipt-text] text-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
$t(planSettingsLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="my-2" />
|
||||
<div
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
data-testid="user-settings-menu-item"
|
||||
@click="handleOpenUserSettings"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2] text-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
$t('userSettings.title')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
<Divider class="my-2 mx-0" />
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
data-testid="logout-menu-item"
|
||||
@click="handleLogout"
|
||||
/>
|
||||
>
|
||||
<i class="icon-[lucide--log-out] text-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
$t('auth.signOut.signOut')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@@ -121,8 +150,24 @@ const planSettingsLabel = isCloud
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
// Backend returns cents despite the *_micros naming convention.
|
||||
const cents = authStore.balance?.amount_micros ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface VueNodeData {
|
||||
}
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
shape?: number
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
@@ -234,7 +235,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
shape: node.shape
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +573,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'shape':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
shape:
|
||||
typeof propertyEvent.newValue === 'number'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
const componentIconSvg = new Image()
|
||||
componentIconSvg.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
|
||||
|
||||
export const usePriceBadge = () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
function updateSubgraphCredits(node: LGraphNode) {
|
||||
if (!node.isSubgraphNode()) return
|
||||
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
|
||||
@@ -33,34 +39,54 @@ export const usePriceBadge = () => {
|
||||
}
|
||||
|
||||
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
|
||||
return (
|
||||
(typeof badge === 'function' ? badge() : badge).icon?.unicode === '\ue96b'
|
||||
)
|
||||
const badgeInstance = typeof badge === 'function' ? badge() : badge
|
||||
if (flags.subscriptionTiersEnabled) {
|
||||
return badgeInstance.icon?.image === componentIconSvg
|
||||
} else {
|
||||
return badgeInstance.icon?.unicode === '\ue96b'
|
||||
}
|
||||
}
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
function getCreditsBadge(price: string): LGraphBadge {
|
||||
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
|
||||
return new LGraphBadge({
|
||||
text: price,
|
||||
iconOptions: {
|
||||
unicode: '\ue96b',
|
||||
fontFamily: 'PrimeIcons',
|
||||
color: isLightTheme
|
||||
? adjustColor('#FABC25', { lightness: 0.5 })
|
||||
: '#FABC25',
|
||||
|
||||
if (flags.subscriptionTiersEnabled) {
|
||||
return new LGraphBadge({
|
||||
text: price,
|
||||
iconOptions: {
|
||||
image: componentIconSvg,
|
||||
size: 8
|
||||
},
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor: isLightTheme
|
||||
? adjustColor('#654020', { lightness: 0.5 })
|
||||
: '#654020',
|
||||
fontSize: 8
|
||||
},
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor: isLightTheme
|
||||
? adjustColor('#8D6932', { lightness: 0.5 })
|
||||
: '#8D6932'
|
||||
})
|
||||
? adjustColor('#8D6932', { lightness: 0.5 })
|
||||
: '#8D6932'
|
||||
})
|
||||
} else {
|
||||
return new LGraphBadge({
|
||||
text: price,
|
||||
iconOptions: {
|
||||
unicode: '\ue96b',
|
||||
fontFamily: 'PrimeIcons',
|
||||
color: isLightTheme
|
||||
? adjustColor('#FABC25', { lightness: 0.5 })
|
||||
: '#FABC25',
|
||||
bgColor: isLightTheme
|
||||
? adjustColor('#654020', { lightness: 0.5 })
|
||||
: '#654020',
|
||||
fontSize: 8
|
||||
},
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor: isLightTheme
|
||||
? adjustColor('#8D6932', { lightness: 0.5 })
|
||||
: '#8D6932'
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
getCreditsBadge,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -12,7 +12,8 @@ export enum ServerFeatureFlag {
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled'
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
||||
SUBSCRIPTION_TIERS_ENABLED = 'subscription_tiers_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,16 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.private_models_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get subscriptionTiersEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
return (
|
||||
remoteConfig.value.subscription_tiers_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.SUBSCRIPTION_TIERS_ENABLED,
|
||||
true // Default to true (new design)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
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: '' }
|
||||
}
|
||||
|
||||
@@ -66,8 +66,12 @@ export class LGraphBadge {
|
||||
const { font } = ctx
|
||||
let iconWidth = 0
|
||||
if (this.icon) {
|
||||
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
|
||||
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
|
||||
if (this.icon.image) {
|
||||
iconWidth = this.icon.size + this.padding
|
||||
} else if (this.icon.unicode) {
|
||||
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
|
||||
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
|
||||
}
|
||||
}
|
||||
ctx.font = `${this.fontSize}px sans-serif`
|
||||
const textWidth = this.text ? ctx.measureText(this.text).width : 0
|
||||
@@ -104,7 +108,8 @@ export class LGraphBadge {
|
||||
// Draw icon if present
|
||||
if (this.icon) {
|
||||
this.icon.draw(ctx, drawX, centerY)
|
||||
drawX += this.icon.fontSize + this.padding / 2 + 4
|
||||
const iconWidth = this.icon.image ? this.icon.size : this.icon.fontSize
|
||||
drawX += iconWidth + this.padding / 2 + 4
|
||||
}
|
||||
|
||||
// Draw badge text
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
export interface LGraphIconOptions {
|
||||
unicode: string
|
||||
unicode?: string
|
||||
fontFamily?: string
|
||||
image?: HTMLImageElement
|
||||
color?: string
|
||||
bgColor?: string
|
||||
fontSize?: number
|
||||
size?: number
|
||||
circlePadding?: number
|
||||
xOffset?: number
|
||||
yOffset?: number
|
||||
}
|
||||
|
||||
export class LGraphIcon {
|
||||
unicode: string
|
||||
unicode?: string
|
||||
fontFamily: string
|
||||
image?: HTMLImageElement
|
||||
color: string
|
||||
bgColor?: string
|
||||
fontSize: number
|
||||
size: number
|
||||
circlePadding: number
|
||||
xOffset: number
|
||||
yOffset: number
|
||||
@@ -22,18 +26,22 @@ export class LGraphIcon {
|
||||
constructor({
|
||||
unicode,
|
||||
fontFamily = 'PrimeIcons',
|
||||
image,
|
||||
color = '#e6c200',
|
||||
bgColor,
|
||||
fontSize = 16,
|
||||
size,
|
||||
circlePadding = 2,
|
||||
xOffset = 0,
|
||||
yOffset = 0
|
||||
}: LGraphIconOptions) {
|
||||
this.unicode = unicode
|
||||
this.fontFamily = fontFamily
|
||||
this.image = image
|
||||
this.color = color
|
||||
this.bgColor = bgColor
|
||||
this.fontSize = fontSize
|
||||
this.size = size ?? fontSize
|
||||
this.circlePadding = circlePadding
|
||||
this.xOffset = xOffset
|
||||
this.yOffset = yOffset
|
||||
@@ -43,26 +51,44 @@ export class LGraphIcon {
|
||||
x += this.xOffset
|
||||
y += this.yOffset
|
||||
|
||||
const { font, textBaseline, textAlign, fillStyle } = ctx
|
||||
if (this.image) {
|
||||
const iconSize = this.size
|
||||
const iconRadius = iconSize / 2 + this.circlePadding
|
||||
|
||||
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.textAlign = 'center'
|
||||
const iconRadius = this.fontSize / 2 + this.circlePadding
|
||||
// Draw icon background circle if bgColor is set
|
||||
if (this.bgColor) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = this.bgColor
|
||||
ctx.fill()
|
||||
if (this.bgColor) {
|
||||
const { fillStyle } = ctx
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = this.bgColor
|
||||
ctx.fill()
|
||||
ctx.fillStyle = fillStyle
|
||||
}
|
||||
|
||||
const imageX = x + this.circlePadding
|
||||
const imageY = y - iconSize / 2
|
||||
ctx.drawImage(this.image, imageX, imageY, iconSize, iconSize)
|
||||
} else if (this.unicode) {
|
||||
const { font, textBaseline, textAlign, fillStyle } = ctx
|
||||
|
||||
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.textAlign = 'center'
|
||||
const iconRadius = this.fontSize / 2 + this.circlePadding
|
||||
|
||||
if (this.bgColor) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = this.bgColor
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.fillStyle = this.color
|
||||
ctx.fillText(this.unicode, x + iconRadius, y)
|
||||
|
||||
ctx.font = font
|
||||
ctx.textBaseline = textBaseline
|
||||
ctx.textAlign = textAlign
|
||||
ctx.fillStyle = fillStyle
|
||||
}
|
||||
// Draw icon
|
||||
ctx.fillStyle = this.color
|
||||
ctx.fillText(this.unicode, x + iconRadius, y)
|
||||
|
||||
ctx.font = font
|
||||
ctx.textBaseline = textBaseline
|
||||
ctx.textAlign = textAlign
|
||||
ctx.fillStyle = fillStyle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +495,7 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
set shape(v: RenderShape | 'default' | 'box' | 'round' | 'circle' | 'card') {
|
||||
const oldValue = this._shape
|
||||
switch (v) {
|
||||
case 'default':
|
||||
this._shape = undefined
|
||||
@@ -514,6 +515,14 @@ export class LGraphNode
|
||||
default:
|
||||
this._shape = v
|
||||
}
|
||||
if (oldValue !== this._shape) {
|
||||
this.graph?.trigger('node:property:changed', {
|
||||
nodeId: this.id,
|
||||
property: 'shape',
|
||||
oldValue,
|
||||
newValue: this._shape
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -851,13 +860,12 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
if (info.widgets_values) {
|
||||
const widgetsWithValue = this.widgets
|
||||
.values()
|
||||
.filter((w) => w.serialize !== false)
|
||||
.filter((_w, idx) => idx < info.widgets_values!.length)
|
||||
widgetsWithValue.forEach(
|
||||
(widget, i) => (widget.value = info.widgets_values![i])
|
||||
)
|
||||
let i = 0
|
||||
for (const widget of this.widgets ?? []) {
|
||||
if (widget.serialize === false) continue
|
||||
if (i >= info.widgets_values.length) break
|
||||
widget.value = info.widgets_values[i++]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
"desktopUserGuide": "دليل مستخدم سطح المكتب",
|
||||
"docs": "الوثائق",
|
||||
"github": "GitHub",
|
||||
"helpFeedback": "المساعدة والتعليقات",
|
||||
"loadingReleases": "جارٍ تحميل الإصدارات...",
|
||||
"managerExtension": "المدير الموسع",
|
||||
"more": "المزيد...",
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
{
|
||||
"Comfy-Desktop_CheckForUpdates": {
|
||||
"label": "Check for Updates"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
|
||||
"label": "Open Custom Nodes Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenInputsFolder": {
|
||||
"label": "Open Inputs Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenLogsFolder": {
|
||||
"label": "Open Logs Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelConfig": {
|
||||
"label": "Open extra_model_paths.yaml"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelsFolder": {
|
||||
"label": "Open Models Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenOutputsFolder": {
|
||||
"label": "Open Outputs Folder"
|
||||
},
|
||||
"Comfy-Desktop_OpenDevTools": {
|
||||
"label": "Open DevTools"
|
||||
},
|
||||
"Comfy-Desktop_OpenUserGuide": {
|
||||
"label": "Desktop User Guide"
|
||||
},
|
||||
"Comfy-Desktop_Quit": {
|
||||
"label": "Quit"
|
||||
},
|
||||
"Comfy-Desktop_Reinstall": {
|
||||
"label": "Reinstall"
|
||||
},
|
||||
"Comfy-Desktop_Restart": {
|
||||
"label": "Restart"
|
||||
},
|
||||
"Comfy_3DViewer_Open3DViewer": {
|
||||
"label": "Open 3D Viewer (Beta) for Selected Node"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"no": "No",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"or": "or",
|
||||
"pressKeysForNewBinding": "Press keys for new binding",
|
||||
"defaultBanner": "default banner",
|
||||
"enableOrDisablePack": "Enable or disable pack",
|
||||
@@ -199,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",
|
||||
@@ -465,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": {
|
||||
@@ -746,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?",
|
||||
@@ -762,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",
|
||||
@@ -1041,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",
|
||||
@@ -1833,15 +1858,34 @@
|
||||
"maxAmount": "(Max. $1,000 USD)",
|
||||
"buyNow": "Buy now",
|
||||
"seeDetails": "See details",
|
||||
"topUp": "Top Up"
|
||||
"topUp": "Top Up",
|
||||
"addMoreCredits": "Add more credits",
|
||||
"addMoreCreditsToRun": "Add more credits to run",
|
||||
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
|
||||
"creditsDescription": "Credits are used to run workflows or partner nodes.",
|
||||
"howManyCredits": "How many credits would you like to add?",
|
||||
"videosEstimate": "~{count} videos*",
|
||||
"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"
|
||||
},
|
||||
"creditsAvailable": "Credits available",
|
||||
"refreshes": "Refreshes {date}",
|
||||
"eventType": "Event Type",
|
||||
"details": "Details",
|
||||
"time": "Time",
|
||||
"additionalInfo": "Additional Info",
|
||||
"model": "Model",
|
||||
"added": "Added",
|
||||
"accountInitialized": "Account initialized"
|
||||
"accountInitialized": "Account initialized",
|
||||
"unified": {
|
||||
"message": "Credits have been unified",
|
||||
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
|
||||
}
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Subscription",
|
||||
@@ -1849,7 +1893,7 @@
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"beta": "BETA",
|
||||
"perMonth": "USD / month",
|
||||
"perMonth": "/ month",
|
||||
"renewsDate": "Renews {date}",
|
||||
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
@@ -1864,6 +1908,10 @@
|
||||
"monthlyBonusDescription": "Monthly credit bonus",
|
||||
"prepaidDescription": "Pre-paid credits",
|
||||
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
|
||||
"creditsRemainingThisMonth": "Credits remaining for this month",
|
||||
"creditsYouveAdded": "Credits you've added",
|
||||
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
||||
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
||||
"nextBillingCycle": "next billing cycle",
|
||||
"yourPlanIncludes": "Your plan includes:",
|
||||
"viewMoreDetails": "View more details",
|
||||
@@ -1874,19 +1922,78 @@
|
||||
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
|
||||
"benefit2": "Up to 30 min runtime per job"
|
||||
},
|
||||
"tiers": {
|
||||
"founder": {
|
||||
"name": "Founder's Edition Standard",
|
||||
"price": "20.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "5,460 monthly credits",
|
||||
"maxDuration": "30 min max duration of each workflow run",
|
||||
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCredits": "Add more credits whenever"
|
||||
}
|
||||
},
|
||||
"standard": {
|
||||
"name": "Standard",
|
||||
"price": "20.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "4,200 monthly credits",
|
||||
"maxDuration": "30 min max duration of each workflow run",
|
||||
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCredits": "Add more credits whenever",
|
||||
"customLoRAs": "Import your own LoRAs",
|
||||
"videoEstimate": "164"
|
||||
}
|
||||
},
|
||||
"creator": {
|
||||
"name": "Creator",
|
||||
"price": "35.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "7,400",
|
||||
"monthlyCreditsLabel": "monthly credits",
|
||||
"maxDuration": "30 min",
|
||||
"maxDurationLabel": "max duration of each workflow run",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"price": "100.00",
|
||||
"benefits": {
|
||||
"monthlyCredits": "21,100 monthly credits",
|
||||
"maxDuration": "1 hr max duration of each workflow run",
|
||||
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCredits": "Add more credits whenever",
|
||||
"customLoRAs": "Import your own LoRAs",
|
||||
"videoEstimate": "821"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": {
|
||||
"title": "Subscribe to",
|
||||
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
|
||||
"subscribe": "Subscribe"
|
||||
},
|
||||
"pricingTable": {
|
||||
"description": "Access cloud-powered ComfyUI workflows with straightforward, usage-based pricing.",
|
||||
"loading": "Loading pricing options...",
|
||||
"loadError": "We couldn't load the pricing table. Please refresh and try again.",
|
||||
"missingConfig": "Stripe pricing table configuration missing. Provide the publishable key and pricing table ID via remote config or .env."
|
||||
},
|
||||
"subscribeToRun": "Subscribe",
|
||||
"subscribeToRunFull": "Subscribe to Run",
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||
"partnerNodesCredits": "Partner Nodes pricing table"
|
||||
"description": "Choose the best plan for you",
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "view enterprise",
|
||||
"partnerNodesCredits": "Partner nodes pricing"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "User Settings",
|
||||
"title": "My Account Settings",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"provider": "Sign-in Provider",
|
||||
@@ -1927,6 +2034,7 @@
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "Learn more",
|
||||
"later": "Later",
|
||||
"noReleaseNotes": "No release notes available."
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
@@ -2082,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}...",
|
||||
"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",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"connectionError": "Please check your connection and try again",
|
||||
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
|
||||
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
|
||||
"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",
|
||||
"uploadModel": "Import",
|
||||
"uploadModelFromCivitai": "Import a model from Civitai",
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"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>",
|
||||
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"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>",
|
||||
"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",
|
||||
"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?",
|
||||
"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 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",
|
||||
"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",
|
||||
"errorUploadFailed": "Failed to import asset. Please try again.",
|
||||
"uploadSuccess": "Model imported successfully!",
|
||||
"ariaLabel": {
|
||||
"assetCard": "{name} - {type} asset",
|
||||
"loadingAsset": "Loading asset"
|
||||
@@ -2271,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')"
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
|
||||
<!-- Model Info Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<p class="mt-0 text-base-foreground rounded-lg">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-3 bg-secondary-background p-3 rounded-lg"
|
||||
>
|
||||
<img
|
||||
v-if="previewImage"
|
||||
:src="previewImage"
|
||||
:alt="metadata?.filename || metadata?.name || 'Model preview'"
|
||||
class="w-14 h-14 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
<p class="m-0 text-base-foreground">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
@@ -40,7 +49,8 @@ import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
defineProps<{
|
||||
metadata: AssetMetadata | null
|
||||
metadata?: AssetMetadata
|
||||
previewImage?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
v-else-if="currentStep === 2"
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
:preview-image="wizardData.previewImage"
|
||||
/>
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
@@ -23,6 +24,7 @@
|
||||
:error="uploadError"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
:preview-image="wizardData.previewImage"
|
||||
/>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
|
||||
@@ -25,8 +25,14 @@
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-row items-start p-4 bg-modal-card-background rounded-lg"
|
||||
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
|
||||
>
|
||||
<img
|
||||
v-if="previewImage"
|
||||
:src="previewImage"
|
||||
:alt="metadata?.filename || metadata?.name || 'Model preview'"
|
||||
class="w-14 h-14 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-base-foreground m-0">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
@@ -63,7 +69,8 @@ import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
defineProps<{
|
||||
status: 'idle' | 'uploading' | 'success' | 'error'
|
||||
error?: string
|
||||
metadata: AssetMetadata | null
|
||||
modelType: string | undefined
|
||||
metadata?: AssetMetadata
|
||||
modelType?: string
|
||||
previewImage?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -9,9 +9,10 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
interface WizardData {
|
||||
url: string
|
||||
metadata: AssetMetadata | null
|
||||
metadata?: AssetMetadata
|
||||
name: string
|
||||
tags: string[]
|
||||
previewImage?: string
|
||||
}
|
||||
|
||||
interface ModelTypeOption {
|
||||
@@ -30,7 +31,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
|
||||
const wizardData = ref<WizardData>({
|
||||
url: '',
|
||||
metadata: null,
|
||||
name: '',
|
||||
tags: []
|
||||
})
|
||||
@@ -91,6 +91,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
// Pre-fill name from metadata
|
||||
wizardData.value.name = metadata.filename || metadata.name || ''
|
||||
|
||||
// Store preview image if available
|
||||
wizardData.value.previewImage = metadata.preview_image
|
||||
|
||||
// Pre-fill model type from metadata tags if available
|
||||
if (metadata.tags && metadata.tags.length > 0) {
|
||||
wizardData.value.tags = metadata.tags
|
||||
@@ -134,6 +137,34 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
wizardData.value.metadata?.name ||
|
||||
'model'
|
||||
|
||||
let previewId: string | undefined
|
||||
|
||||
// Upload preview image first if available
|
||||
if (wizardData.value.previewImage) {
|
||||
try {
|
||||
const baseFilename = filename.split('.')[0]
|
||||
|
||||
// Extract extension from data URL MIME type
|
||||
let extension = 'png'
|
||||
const mimeMatch = wizardData.value.previewImage.match(
|
||||
/^data:image\/([^;]+);/
|
||||
)
|
||||
if (mimeMatch) {
|
||||
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
|
||||
}
|
||||
|
||||
const previewAsset = await assetService.uploadAssetFromBase64({
|
||||
data: wizardData.value.previewImage,
|
||||
name: `${baseFilename}_preview.${extension}`,
|
||||
tags: ['preview']
|
||||
})
|
||||
previewId = previewAsset.id
|
||||
} catch (error) {
|
||||
console.error('Failed to upload preview image:', error)
|
||||
// Continue with model upload even if preview fails
|
||||
}
|
||||
}
|
||||
|
||||
await assetService.uploadAssetFromUrl({
|
||||
url: wizardData.value.url,
|
||||
name: filename,
|
||||
@@ -142,7 +173,8 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
source: 'civitai',
|
||||
source_url: wizardData.value.url,
|
||||
model_type: selectedModelType.value
|
||||
}
|
||||
},
|
||||
preview_id: previewId
|
||||
})
|
||||
|
||||
uploadStatus.value = 'success'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ const zAssetMetadata = z.object({
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
preview_url: z.string().optional(),
|
||||
preview_image: z.string().optional(),
|
||||
validation: zValidationResult.optional()
|
||||
})
|
||||
|
||||
|
||||
@@ -392,6 +392,59 @@ function createAssetService() {
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an asset from base64 data
|
||||
*
|
||||
* @param params - Upload parameters
|
||||
* @param params.data - Base64 data URL (e.g., "data:image/png;base64,...")
|
||||
* @param params.name - Display name (determines extension)
|
||||
* @param params.tags - Optional freeform tags
|
||||
* @param params.user_metadata - Optional custom metadata object
|
||||
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
|
||||
* @throws Error if upload fails
|
||||
*/
|
||||
async function uploadAssetFromBase64(params: {
|
||||
data: string
|
||||
name: string
|
||||
tags?: string[]
|
||||
user_metadata?: Record<string, any>
|
||||
}): Promise<AssetItem & { created_new: boolean }> {
|
||||
// Validate that data is a data URL
|
||||
if (!params.data || !params.data.startsWith('data:')) {
|
||||
throw new Error(
|
||||
'Invalid data URL: expected a string starting with "data:"'
|
||||
)
|
||||
}
|
||||
|
||||
// Convert base64 data URL to Blob
|
||||
const blob = await fetch(params.data).then((r) => r.blob())
|
||||
|
||||
// Create FormData and append the blob
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, params.name)
|
||||
|
||||
if (params.tags) {
|
||||
formData.append('tags', JSON.stringify(params.tags))
|
||||
}
|
||||
|
||||
if (params.user_metadata) {
|
||||
formData.append('user_metadata', JSON.stringify(params.user_metadata))
|
||||
}
|
||||
|
||||
const res = await api.fetchApi(ASSETS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to upload asset from base64: ${res.status} ${res.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
@@ -402,7 +455,8 @@ function createAssetService() {
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl
|
||||
uploadAssetFromUrl,
|
||||
uploadAssetFromBase64
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -24,8 +24,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -51,12 +52,18 @@ const emit = defineEmits<{
|
||||
subscribed: []
|
||||
}>()
|
||||
|
||||
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
|
||||
useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
const shouldUseStripePricing = computed(
|
||||
() => isCloud && Boolean(flags.subscriptionTiersEnabled)
|
||||
)
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isPolling = ref(false)
|
||||
let pollInterval: number | null = null
|
||||
const isAwaitingStripeSubscription = ref(false)
|
||||
|
||||
const POLL_INTERVAL_MS = 3000 // Poll every 3 seconds
|
||||
const MAX_POLL_DURATION_MS = 5 * 60 * 1000 // Stop polling after 5 minutes
|
||||
@@ -102,11 +109,27 @@ const stopPolling = () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
[isAwaitingStripeSubscription, isActiveSubscription],
|
||||
([awaiting, isActive]) => {
|
||||
if (shouldUseStripePricing.value && awaiting && isActive) {
|
||||
emit('subscribed')
|
||||
isAwaitingStripeSubscription.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked')
|
||||
}
|
||||
|
||||
if (shouldUseStripePricing.value) {
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
await subscribe()
|
||||
@@ -120,5 +143,6 @@ const handleSubscribe = async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
isAwaitingStripeSubscription.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-xs text-text-primary" />
|
||||
<div class="flex flex-col items-start gap-0 self-stretch">
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<i class="pi pi-check text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ $t('subscription.benefits.benefit1') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-xs text-text-primary" />
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<i class="pi pi-check text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ $t('subscription.benefits.benefit2') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="$t('subscription.viewMoreDetails')"
|
||||
text
|
||||
icon="pi pi-external-link"
|
||||
icon-pos="left"
|
||||
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-xs text-text-secondary'
|
||||
},
|
||||
label: {
|
||||
class: 'text-sm text-text-secondary'
|
||||
}
|
||||
}"
|
||||
@click="handleViewMoreDetails"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const handleViewMoreDetails = () => {
|
||||
window.open('https://www.comfy.org/cloud/pricing', '_blank')
|
||||
}
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
@@ -19,9 +19,12 @@
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ tierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{
|
||||
$t('subscription.perMonth')
|
||||
}}</span>
|
||||
@@ -59,7 +62,7 @@
|
||||
class: 'text-text-primary'
|
||||
}
|
||||
}"
|
||||
@click="manageSubscription"
|
||||
@click="showSubscriptionDialog"
|
||||
/>
|
||||
<SubscribeButton
|
||||
v-else
|
||||
@@ -75,17 +78,6 @@
|
||||
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm">
|
||||
{{ $t('subscription.partnerNodesBalance') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.partnerNodesDescription') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
@@ -112,7 +104,7 @@
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-secondary">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton
|
||||
@@ -121,7 +113,7 @@
|
||||
height="2rem"
|
||||
/>
|
||||
<div v-else class="text-2xl font-bold">
|
||||
${{ totalCredits }}
|
||||
{{ totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,12 +125,18 @@
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<div v-else class="text-sm text-text-secondary font-bold">
|
||||
${{ monthlyBonusCredits }}
|
||||
<div
|
||||
v-else
|
||||
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
|
||||
>
|
||||
{{ monthlyBonusCredits }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.monthlyBonusDescription') }}
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div
|
||||
class="text-sm truncate text-muted"
|
||||
:title="$t('subscription.creditsRemainingThisMonth')"
|
||||
>
|
||||
{{ $t('subscription.creditsRemainingThisMonth') }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="refreshTooltip"
|
||||
@@ -146,7 +144,7 @@
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="h-4 w-4"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
@@ -161,12 +159,18 @@
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<div v-else class="text-sm text-text-secondary font-bold">
|
||||
${{ prepaidCredits }}
|
||||
<div
|
||||
v-else
|
||||
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
|
||||
>
|
||||
{{ prepaidCredits }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.prepaidDescription') }}
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div
|
||||
class="text-sm truncate text-muted"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="$t('subscription.prepaidCreditsInfo')"
|
||||
@@ -174,7 +178,7 @@
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="h-4 w-4"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:pt="{
|
||||
icon: {
|
||||
class: 'text-text-secondary text-xs'
|
||||
@@ -190,8 +194,7 @@
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-text-secondary underline hover:text-text-secondary"
|
||||
style="text-decoration: underline"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
@@ -216,14 +219,47 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="text-sm">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<SubscriptionBenefits />
|
||||
<div class="flex flex-col gap-0">
|
||||
<div
|
||||
v-for="benefit in tierBenefits"
|
||||
:key="benefit.key"
|
||||
class="flex items-center gap-2 py-2"
|
||||
>
|
||||
<i
|
||||
v-if="benefit.type === 'feature'"
|
||||
class="pi pi-check text-xs text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-else-if="benefit.type === 'metric' && benefit.value"
|
||||
class="text-sm font-normal whitespace-nowrap text-text-primary"
|
||||
>
|
||||
{{ benefit.value }}
|
||||
</span>
|
||||
<span class="text-sm text-muted">
|
||||
{{ benefit.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View More Details - Outside main content -->
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<i class="pi pi-external-link text-muted"></i>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline hover:opacity-80 text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewMoreDetailsPlans') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -307,28 +343,79 @@
|
||||
import Button from 'primevue/button'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
formattedMonthlyPrice,
|
||||
manageSubscription,
|
||||
handleInvoiceHistory
|
||||
} = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
// Tier data - hardcoded for Creator tier as requested
|
||||
const tierName = computed(() => t('subscription.tiers.creator.name'))
|
||||
const tierPrice = computed(() => t('subscription.tiers.creator.price'))
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed(() => {
|
||||
const baseBenefits: Benefit[] = [
|
||||
{
|
||||
key: 'monthlyCredits',
|
||||
type: 'metric',
|
||||
value: t('subscription.tiers.creator.benefits.monthlyCredits'),
|
||||
label: t('subscription.tiers.creator.benefits.monthlyCreditsLabel')
|
||||
},
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t('subscription.tiers.creator.benefits.maxDuration'),
|
||||
label: t('subscription.tiers.creator.benefits.maxDurationLabel')
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.tiers.creator.benefits.gpuLabel')
|
||||
},
|
||||
{
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.tiers.creator.benefits.addCreditsLabel')
|
||||
},
|
||||
{
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.tiers.creator.benefits.customLoRAsLabel')
|
||||
}
|
||||
]
|
||||
|
||||
return baseBenefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
|
||||
@@ -1,5 +1,68 @@
|
||||
<template>
|
||||
<div class="relative grid h-full grid-cols-5">
|
||||
<div
|
||||
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
|
||||
class="flex flex-col gap-6 md:flex-row md:items-start md:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-2 text-left md:max-w-2xl">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-text-secondary"
|
||||
>
|
||||
{{ $t('subscription.required.title') }}
|
||||
<CloudBadge
|
||||
reverse-order
|
||||
no-padding
|
||||
background-color="var(--p-dialog-background)"
|
||||
use-subscription
|
||||
/>
|
||||
</div>
|
||||
<div class="text-3xl font-semibold leading-tight md:text-4xl">
|
||||
{{ $t('subscription.description') }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
class="h-10 w-10 shrink-0 text-text-secondary hover:bg-white/10"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StripePricingTable class="flex-1" />
|
||||
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center">
|
||||
<p class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.haveQuestions') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="$t('subscription.contactUs')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-comments"
|
||||
icon-pos="right"
|
||||
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>
|
||||
<Button
|
||||
:label="$t('subscription.viewEnterprise')"
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-external-link"
|
||||
icon-pos="right"
|
||||
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
|
||||
@click="handleViewEnterprise"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="legacy-dialog relative grid h-full grid-cols-5">
|
||||
<!-- Custom close button -->
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
@@ -7,7 +70,7 @@
|
||||
rounded
|
||||
class="absolute top-2.5 right-2.5 z-10 h-8 w-8 p-0 text-white hover:bg-white/20"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onClose"
|
||||
@click="handleClose"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -72,13 +135,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.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'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
@@ -86,19 +155,119 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { formattedMonthlyPrice } = useSubscription()
|
||||
const { formattedMonthlyPrice, fetchStatus, isActiveSubscription } =
|
||||
useSubscription()
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
const subscriptionTiersEnabled = featureFlag(
|
||||
'subscription_tiers_enabled',
|
||||
false
|
||||
)
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const showStripePricingTable = computed(
|
||||
() =>
|
||||
subscriptionTiersEnabled.value &&
|
||||
isCloud &&
|
||||
window.__CONFIG__?.subscription_required
|
||||
)
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const MAX_POLL_DURATION_MS = 5 * 60 * 1000
|
||||
let pollInterval: number | null = null
|
||||
let pollStartTime = 0
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollStartTime = Date.now()
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await fetchStatus()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SubscriptionDialog] Failed to poll subscription status',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
pollInterval = window.setInterval(() => {
|
||||
if (Date.now() - pollStartTime > MAX_POLL_DURATION_MS) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
void poll()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
watch(
|
||||
showStripePricingTable,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => isActiveSubscription.value,
|
||||
(isActive) => {
|
||||
if (isActive && showStripePricingTable.value) {
|
||||
emit('close', true)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleSubscribed = () => {
|
||||
emit('close', true)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const handleContactUs = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleViewEnterprise = () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'docs',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
.legacy-dialog :deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.p-button) {
|
||||
.legacy-dialog :deep(.p-button) {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -9,11 +9,11 @@ import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||
|
||||
type CloudSubscriptionCheckoutResponse = {
|
||||
@@ -37,7 +37,7 @@ function useSubscriptionInternal() {
|
||||
return subscriptionStatus.value?.is_active ?? false
|
||||
})
|
||||
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { showSubscriptionRequiredDialog } = useDialogService()
|
||||
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
@@ -102,7 +102,7 @@ function useSubscriptionInternal() {
|
||||
useTelemetry()?.trackSubscription('modal_opened')
|
||||
}
|
||||
|
||||
void dialogService.showSubscriptionRequiredDialog()
|
||||
void showSubscriptionRequiredDialog()
|
||||
}
|
||||
|
||||
const shouldWatchCancellation = (): boolean =>
|
||||
|
||||
@@ -1,58 +1,41 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling subscription credit calculations and formatting
|
||||
*/
|
||||
export function useSubscriptionCredits() {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const totalCredits = computed(() => {
|
||||
if (!authStore.balance?.amount_micros) return '0.00'
|
||||
try {
|
||||
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSubscriptionCredits] Error formatting total credits:',
|
||||
error
|
||||
)
|
||||
return '0.00'
|
||||
}
|
||||
})
|
||||
const formatBalance = (maybeCents?: number) => {
|
||||
// Backend returns cents despite the *_micros naming convention.
|
||||
const cents = maybeCents ?? 0
|
||||
const amount = formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
})
|
||||
return amount
|
||||
}
|
||||
|
||||
const monthlyBonusCredits = computed(() => {
|
||||
if (!authStore.balance?.cloud_credit_balance_micros) return '0.00'
|
||||
try {
|
||||
return formatMetronomeCurrency(
|
||||
authStore.balance.cloud_credit_balance_micros,
|
||||
'usd'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSubscriptionCredits] Error formatting monthly bonus credits:',
|
||||
error
|
||||
)
|
||||
return '0.00'
|
||||
}
|
||||
})
|
||||
const totalCredits = computed(() =>
|
||||
formatBalance(authStore.balance?.amount_micros)
|
||||
)
|
||||
|
||||
const prepaidCredits = computed(() => {
|
||||
if (!authStore.balance?.prepaid_balance_micros) return '0.00'
|
||||
try {
|
||||
return formatMetronomeCurrency(
|
||||
authStore.balance.prepaid_balance_micros,
|
||||
'usd'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSubscriptionCredits] Error formatting prepaid credits:',
|
||||
error
|
||||
)
|
||||
return '0.00'
|
||||
}
|
||||
})
|
||||
const monthlyBonusCredits = computed(() =>
|
||||
formatBalance(authStore.balance?.cloud_credit_balance_micros)
|
||||
)
|
||||
|
||||
const prepaidCredits = computed(() =>
|
||||
formatBalance(authStore.balance?.prepaid_balance_micros)
|
||||
)
|
||||
|
||||
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
|
||||
@@ -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,7 +36,19 @@ export const useSubscriptionDialog = () => {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 700px;'
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,4 +37,7 @@ export type RemoteConfig = {
|
||||
model_upload_button_enabled?: boolean
|
||||
asset_update_options_enabled?: boolean
|
||||
private_models_enabled?: boolean
|
||||
subscription_tiers_enabled?: boolean
|
||||
stripe_publishable_key?: string
|
||||
stripe_pricing_table_id?: string
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -11,6 +12,7 @@ import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
@@ -33,6 +35,8 @@ export function useSettingUI(
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
@@ -81,7 +85,7 @@ export function useSettingUI(
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
|
||||
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,6 +106,12 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
const shouldShowPlanCreditsPanel = computed(() => {
|
||||
if (!subscriptionPanel) return false
|
||||
if (!flags.subscriptionTiersEnabled) return true
|
||||
return isActiveSubscription.value
|
||||
})
|
||||
|
||||
const userPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'user',
|
||||
@@ -154,9 +164,7 @@ export function useSettingUI(
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isElectron() ? [serverConfigPanel] : []),
|
||||
...(isCloud &&
|
||||
window.__CONFIG__?.subscription_required &&
|
||||
subscriptionPanel
|
||||
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
|
||||
? [subscriptionPanel]
|
||||
: [])
|
||||
].filter((panel) => panel.component)
|
||||
@@ -191,8 +199,7 @@ export function useSettingUI(
|
||||
children: [
|
||||
userPanel.node,
|
||||
...(isLoggedIn.value &&
|
||||
isCloud &&
|
||||
window.__CONFIG__?.subscription_required &&
|
||||
shouldShowPlanCreditsPanel.value &&
|
||||
subscriptionPanel
|
||||
? [subscriptionPanel.node]
|
||||
: []),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { until } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { compare } from 'semver'
|
||||
import { compare, valid } from 'semver'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -24,10 +24,12 @@ export const useReleaseStore = defineStore('release', () => {
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Current ComfyUI version
|
||||
const currentComfyUIVersion = computed(
|
||||
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
|
||||
)
|
||||
const currentVersion = computed(() => {
|
||||
if (isCloud) {
|
||||
return systemStatsStore?.systemStats?.system?.cloud_version ?? ''
|
||||
}
|
||||
return systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
|
||||
})
|
||||
|
||||
// Release data from settings
|
||||
const locale = computed(() => settingStore.get('Comfy.Locale'))
|
||||
@@ -55,22 +57,33 @@ export const useReleaseStore = defineStore('release', () => {
|
||||
// Helper constants
|
||||
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
|
||||
|
||||
const compareVersions = (
|
||||
releaseVersion: string,
|
||||
currentVer: string
|
||||
): number => {
|
||||
if (valid(releaseVersion) && valid(currentVer)) {
|
||||
return compare(releaseVersion, currentVer)
|
||||
}
|
||||
// Non-semver (e.g. git hash): assume different = newer
|
||||
return releaseVersion === currentVer ? 0 : 1
|
||||
}
|
||||
|
||||
// New version available?
|
||||
const isNewVersionAvailable = computed(
|
||||
() =>
|
||||
!!recentRelease.value &&
|
||||
compare(
|
||||
compareVersions(
|
||||
recentRelease.value.version,
|
||||
currentComfyUIVersion.value || '0.0.0'
|
||||
currentVersion.value || '0.0.0'
|
||||
) > 0
|
||||
)
|
||||
|
||||
const isLatestVersion = computed(
|
||||
() =>
|
||||
!!recentRelease.value &&
|
||||
compare(
|
||||
compareVersions(
|
||||
recentRelease.value.version,
|
||||
currentComfyUIVersion.value || '0.0.0'
|
||||
currentVersion.value || '0.0.0'
|
||||
) === 0
|
||||
)
|
||||
|
||||
@@ -158,23 +171,25 @@ export const useReleaseStore = defineStore('release', () => {
|
||||
return true
|
||||
})
|
||||
|
||||
// Show "What's New" popup
|
||||
const shouldShowPopup = computed(() => {
|
||||
// Only show on desktop version
|
||||
if (!isElectron() || isCloud) {
|
||||
if (!isElectron() && !isCloud) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if notifications are disabled
|
||||
if (!showVersionUpdates.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isLatestVersion.value) {
|
||||
if (!recentRelease.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip version check if current version isn't semver (e.g. git hash)
|
||||
const skipVersionCheck = !valid(currentVersion.value)
|
||||
if (!skipVersionCheck && !isLatestVersion.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Hide if already seen
|
||||
if (
|
||||
releaseVersion.value === recentRelease.value.version &&
|
||||
releaseStatus.value === "what's new seen"
|
||||
@@ -225,8 +240,7 @@ export const useReleaseStore = defineStore('release', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip fetching if notifications are disabled
|
||||
if (!showVersionUpdates.value) {
|
||||
if (!isCloud && !showVersionUpdates.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -248,8 +262,8 @@ export const useReleaseStore = defineStore('release', () => {
|
||||
}
|
||||
|
||||
const fetchedReleases = await releaseService.getReleases({
|
||||
project: 'comfyui',
|
||||
current_version: currentComfyUIVersion.value,
|
||||
project: isCloud ? 'cloud' : 'comfyui',
|
||||
current_version: currentVersion.value,
|
||||
form_factor: systemStatsStore.getFormFactor(),
|
||||
locale: stringToLocale(locale.value)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
@@ -39,7 +39,15 @@
|
||||
</div>
|
||||
|
||||
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
|
||||
<div v-if="isApiNode" class="icon-[lucide--dollar-sign] size-4" />
|
||||
<div
|
||||
v-if="isApiNode"
|
||||
:class="
|
||||
flags.subscriptionTiersEnabled
|
||||
? 'icon-[lucide--component]'
|
||||
: 'icon-[lucide--dollar-sign]'
|
||||
"
|
||||
class="size-4"
|
||||
/>
|
||||
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
@@ -76,13 +84,16 @@
|
||||
v-tooltip.top="enterSubgraphTooltipConfig"
|
||||
type="transparent"
|
||||
data-testid="subgraph-enter-button"
|
||||
class="size-5"
|
||||
class="ml-2 text-node-component-header h-5"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
@dblclick.stop
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--picture-in-picture] size-5 text-node-component-header-icon"
|
||||
></i>
|
||||
<div
|
||||
class="min-w-max rounded-sm bg-node-component-surface px-1 py-0.5 text-xs flex items-center gap-1"
|
||||
>
|
||||
{{ $t('g.edit') }}
|
||||
<i class="icon-[lucide--scaling] size-5"></i>
|
||||
</div>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,8 +107,9 @@ import IconButton from '@/components/button/IconButton.vue'
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
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'
|
||||
@@ -126,6 +138,8 @@ const emit = defineEmits<{
|
||||
'enter-subgraph': []
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
@@ -202,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?.()
|
||||
|
||||
@@ -386,11 +386,12 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
key: 'top-up-credits',
|
||||
component: TopUpCreditsDialogContent,
|
||||
headerComponent: ComfyOrgHeader,
|
||||
props: options,
|
||||
dialogComponentProps: {
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-3!' }
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0!' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user