mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Merge branch 'main' into manager/dialog-design-modify
This commit is contained in:
@@ -119,8 +119,7 @@ class NodeSlotReference {
|
||||
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
||||
|
||||
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
console.warn(
|
||||
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
||||
{
|
||||
nodePos: [node.pos[0], node.pos[1]],
|
||||
|
||||
@@ -27,7 +27,7 @@ test.describe('Feature Flags', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.type === 'feature_flags') {
|
||||
window.__capturedMessages.clientFeatureFlags = parsed
|
||||
window.__capturedMessages!.clientFeatureFlags = parsed
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, ignore
|
||||
@@ -41,7 +41,7 @@ test.describe('Feature Flags', () => {
|
||||
window['app']?.api?.serverFeatureFlags &&
|
||||
Object.keys(window['app'].api.serverFeatureFlags).length > 0
|
||||
) {
|
||||
window.__capturedMessages.serverFeatureFlags =
|
||||
window.__capturedMessages!.serverFeatureFlags =
|
||||
window['app'].api.serverFeatureFlags
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
@@ -57,8 +57,8 @@ test.describe('Feature Flags', () => {
|
||||
// Wait for both client and server feature flags
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window.__capturedMessages.clientFeatureFlags !== null &&
|
||||
window.__capturedMessages.serverFeatureFlags !== null,
|
||||
window.__capturedMessages!.clientFeatureFlags !== null &&
|
||||
window.__capturedMessages!.serverFeatureFlags !== null,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
@@ -66,27 +66,27 @@ test.describe('Feature Flags', () => {
|
||||
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
||||
|
||||
// Verify client sent feature flags
|
||||
expect(messages.clientFeatureFlags).toBeTruthy()
|
||||
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||
expect(messages.clientFeatureFlags).toHaveProperty('data')
|
||||
expect(messages.clientFeatureFlags.data).toHaveProperty(
|
||||
expect(messages!.clientFeatureFlags).toBeTruthy()
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('data')
|
||||
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(
|
||||
typeof messages.clientFeatureFlags.data.supports_preview_metadata
|
||||
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
|
||||
).toBe('boolean')
|
||||
|
||||
// Verify server sent feature flags back
|
||||
expect(messages.serverFeatureFlags).toBeTruthy()
|
||||
expect(messages.serverFeatureFlags).toHaveProperty(
|
||||
expect(messages!.serverFeatureFlags).toBeTruthy()
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
|
||||
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
|
||||
'boolean'
|
||||
)
|
||||
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
|
||||
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
|
||||
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
|
||||
|
||||
await newPage.close()
|
||||
})
|
||||
@@ -96,7 +96,7 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Get the actual server feature flags from the backend
|
||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverFeatureFlags
|
||||
return window['app']!.api.serverFeatureFlags
|
||||
})
|
||||
|
||||
// Verify we received real feature flags from the backend
|
||||
@@ -115,7 +115,7 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Test serverSupportsFeature with real backend flags
|
||||
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverSupportsFeature(
|
||||
return window['app']!.api.serverSupportsFeature(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
})
|
||||
@@ -124,15 +124,17 @@ test.describe('Feature Flags', () => {
|
||||
|
||||
// Test non-existent feature - should always return false
|
||||
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
|
||||
return window['app']!.api.serverSupportsFeature(
|
||||
'non_existent_feature_xyz'
|
||||
)
|
||||
})
|
||||
expect(supportsNonExistent).toBe(false)
|
||||
|
||||
// Test that the method only returns true for boolean true values
|
||||
const testResults = await comfyPage.page.evaluate(() => {
|
||||
// Temporarily modify serverFeatureFlags to test behavior
|
||||
const original = window['app'].api.serverFeatureFlags
|
||||
window['app'].api.serverFeatureFlags = {
|
||||
const original = window['app']!.api.serverFeatureFlags
|
||||
window['app']!.api.serverFeatureFlags = {
|
||||
bool_true: true,
|
||||
bool_false: false,
|
||||
string_value: 'yes',
|
||||
@@ -141,15 +143,15 @@ test.describe('Feature Flags', () => {
|
||||
}
|
||||
|
||||
const results = {
|
||||
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
|
||||
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
|
||||
string_value: window['app'].api.serverSupportsFeature('string_value'),
|
||||
number_value: window['app'].api.serverSupportsFeature('number_value'),
|
||||
null_value: window['app'].api.serverSupportsFeature('null_value')
|
||||
bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
|
||||
bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
|
||||
string_value: window['app']!.api.serverSupportsFeature('string_value'),
|
||||
number_value: window['app']!.api.serverSupportsFeature('number_value'),
|
||||
null_value: window['app']!.api.serverSupportsFeature('null_value')
|
||||
}
|
||||
|
||||
// Restore original
|
||||
window['app'].api.serverFeatureFlags = original
|
||||
window['app']!.api.serverFeatureFlags = original
|
||||
return results
|
||||
})
|
||||
|
||||
@@ -166,20 +168,20 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Test getServerFeature method
|
||||
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature('supports_preview_metadata')
|
||||
return window['app']!.api.getServerFeature('supports_preview_metadata')
|
||||
})
|
||||
expect(typeof previewMetadataValue).toBe('boolean')
|
||||
|
||||
// Test getting max_upload_size
|
||||
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature('max_upload_size')
|
||||
return window['app']!.api.getServerFeature('max_upload_size')
|
||||
})
|
||||
expect(typeof maxUploadSize).toBe('number')
|
||||
expect(maxUploadSize).toBeGreaterThan(0)
|
||||
|
||||
// Test getServerFeature with default value for non-existent feature
|
||||
const defaultValue = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature(
|
||||
return window['app']!.api.getServerFeature(
|
||||
'non_existent_feature_xyz',
|
||||
'default'
|
||||
)
|
||||
@@ -192,7 +194,7 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Test getServerFeatures returns all flags
|
||||
const allFeatures = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeatures()
|
||||
return window['app']!.api.getServerFeatures()
|
||||
})
|
||||
|
||||
expect(allFeatures).toBeTruthy()
|
||||
@@ -205,14 +207,14 @@ test.describe('Feature Flags', () => {
|
||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||
// Test that getClientFeatureFlags returns a copy
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
const flags1 = window['app'].api.getClientFeatureFlags()
|
||||
const flags2 = window['app'].api.getClientFeatureFlags()
|
||||
const flags1 = window['app']!.api.getClientFeatureFlags()
|
||||
const flags2 = window['app']!.api.getClientFeatureFlags()
|
||||
|
||||
// Modify the first object
|
||||
flags1.test_modification = true
|
||||
|
||||
// Get flags again to check if original was modified
|
||||
const flags3 = window['app'].api.getClientFeatureFlags()
|
||||
const flags3 = window['app']!.api.getClientFeatureFlags()
|
||||
|
||||
return {
|
||||
areEqual: flags1 === flags2,
|
||||
@@ -238,14 +240,14 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
// Get a copy of server features
|
||||
const features1 = window['app'].api.getServerFeatures()
|
||||
const features1 = window['app']!.api.getServerFeatures()
|
||||
|
||||
// Try to modify it
|
||||
features1.supports_preview_metadata = false
|
||||
features1.new_feature = 'added'
|
||||
|
||||
// Get another copy
|
||||
const features2 = window['app'].api.getServerFeatures()
|
||||
const features2 = window['app']!.api.getServerFeatures()
|
||||
|
||||
return {
|
||||
modifiedValue: features1.supports_preview_metadata,
|
||||
@@ -274,7 +276,8 @@ test.describe('Feature Flags', () => {
|
||||
// Set up monitoring before navigation
|
||||
await newPage.addInitScript(() => {
|
||||
// Track when various app components are ready
|
||||
;(window as any).__appReadiness = {
|
||||
|
||||
window.__appReadiness = {
|
||||
featureFlagsReceived: false,
|
||||
apiInitialized: false,
|
||||
appInitialized: false
|
||||
@@ -286,7 +289,10 @@ test.describe('Feature Flags', () => {
|
||||
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined
|
||||
) {
|
||||
;(window as any).__appReadiness.featureFlagsReceived = true
|
||||
window.__appReadiness = {
|
||||
...window.__appReadiness,
|
||||
featureFlagsReceived: true
|
||||
}
|
||||
clearInterval(checkFeatureFlags)
|
||||
}
|
||||
}, 10)
|
||||
@@ -294,7 +300,10 @@ test.describe('Feature Flags', () => {
|
||||
// Monitor API initialization
|
||||
const checkApi = setInterval(() => {
|
||||
if (window['app']?.api) {
|
||||
;(window as any).__appReadiness.apiInitialized = true
|
||||
window.__appReadiness = {
|
||||
...window.__appReadiness,
|
||||
apiInitialized: true
|
||||
}
|
||||
clearInterval(checkApi)
|
||||
}
|
||||
}, 10)
|
||||
@@ -302,7 +311,10 @@ test.describe('Feature Flags', () => {
|
||||
// Monitor app initialization
|
||||
const checkApp = setInterval(() => {
|
||||
if (window['app']?.graph) {
|
||||
;(window as any).__appReadiness.appInitialized = true
|
||||
window.__appReadiness = {
|
||||
...window.__appReadiness,
|
||||
appInitialized: true
|
||||
}
|
||||
clearInterval(checkApp)
|
||||
}
|
||||
}, 10)
|
||||
@@ -331,8 +343,8 @@ test.describe('Feature Flags', () => {
|
||||
// Get readiness state
|
||||
const readiness = await newPage.evaluate(() => {
|
||||
return {
|
||||
...(window as any).__appReadiness,
|
||||
currentFlags: window['app'].api.serverFeatureFlags
|
||||
...window.__appReadiness,
|
||||
currentFlags: window['app']!.api.serverFeatureFlags
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,15 +2,17 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
// Helper function to pan canvas and select node
|
||||
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
|
||||
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
const nodePos = await nodeRef.getPosition()
|
||||
|
||||
await comfyPage.page.evaluate((pos) => {
|
||||
const app = window['app']
|
||||
const app = window['app']!
|
||||
const canvas = app.canvas
|
||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
||||
@@ -345,7 +347,7 @@ This is documentation for a custom node.
|
||||
|
||||
// Find and select a custom/group node
|
||||
const nodeRefs = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.nodes.map((n: any) => n.id)
|
||||
return window['app']!.graph!.nodes.map((n) => n.id)
|
||||
})
|
||||
if (nodeRefs.length > 0) {
|
||||
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -15,7 +16,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = async (comfyPage: any) => {
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
|
||||
@@ -419,7 +419,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
@@ -505,7 +505,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^11.0.3",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
|
||||
@@ -120,8 +120,8 @@ describe('formatUtil', () => {
|
||||
})
|
||||
|
||||
it('should handle null and undefined gracefully', () => {
|
||||
expect(getMediaTypeFromFilename(null as any)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(undefined as any)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(null)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(undefined)).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle special characters in filenames', () => {
|
||||
|
||||
@@ -537,7 +537,9 @@ export function truncateFilename(
|
||||
* @param filename The filename to analyze
|
||||
* @returns The media type: 'image', 'video', 'audio', or '3D'
|
||||
*/
|
||||
export function getMediaTypeFromFilename(filename: string): MediaType {
|
||||
export function getMediaTypeFromFilename(
|
||||
filename: string | null | undefined
|
||||
): MediaType {
|
||||
if (!filename) return 'image'
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'image'
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -186,6 +186,9 @@ catalogs:
|
||||
jsdom:
|
||||
specifier: ^27.4.0
|
||||
version: 27.4.0
|
||||
jsonata:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
knip:
|
||||
specifier: ^5.75.1
|
||||
version: 5.75.1
|
||||
@@ -449,6 +452,9 @@ importers:
|
||||
glob:
|
||||
specifier: ^11.0.3
|
||||
version: 11.0.3
|
||||
jsonata:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
@@ -6045,6 +6051,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonata@2.1.0:
|
||||
resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
jsonc-eslint-parser@2.4.0:
|
||||
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -14403,6 +14413,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonata@2.1.0: {}
|
||||
|
||||
jsonc-eslint-parser@2.4.0:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
@@ -62,6 +62,7 @@ catalog:
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsonata: ^2.1.0
|
||||
jsdom: ^27.4.0
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
|
||||
@@ -14,6 +14,7 @@ interface IdleDeadline {
|
||||
interface IDisposable {
|
||||
dispose(): void
|
||||
}
|
||||
type GlobalWindow = typeof globalThis
|
||||
|
||||
/**
|
||||
* Internal implementation function that handles the actual scheduling logic.
|
||||
@@ -21,7 +22,7 @@ interface IDisposable {
|
||||
* or fall back to setTimeout-based implementation.
|
||||
*/
|
||||
let _runWhenIdle: (
|
||||
targetWindow: any,
|
||||
targetWindow: GlobalWindow,
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
@@ -37,7 +38,7 @@ export let runWhenGlobalIdle: (
|
||||
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
;(function () {
|
||||
const safeGlobal: any = globalThis
|
||||
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
|
||||
|
||||
if (
|
||||
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -42,7 +43,8 @@ function createWrapper() {
|
||||
queueProgressOverlay: {
|
||||
viewJobHistory: 'View job history',
|
||||
expandCollapsedQueue: 'Expand collapsed queue',
|
||||
activeJobsShort: '{count} active | {count} active'
|
||||
activeJobsShort: '{count} active | {count} active',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +58,12 @@ function createWrapper() {
|
||||
SubgraphBreadcrumb: true,
|
||||
QueueProgressOverlay: true,
|
||||
CurrentUserButton: true,
|
||||
LoginButton: true
|
||||
LoginButton: true,
|
||||
ContextMenu: {
|
||||
name: 'ContextMenu',
|
||||
props: ['model'],
|
||||
template: '<div />'
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
@@ -134,4 +141,24 @@ describe('TopMenuSection', () => {
|
||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
expect(queueButton.text()).toContain('3 active')
|
||||
})
|
||||
|
||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||
const wrapper = createWrapper()
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.label).toBe('Clear queue')
|
||||
expect(model[0]?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables the clear queue context menu item when queued jobs exist', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||
|
||||
await nextTick()
|
||||
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
class="px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
@@ -57,6 +58,7 @@
|
||||
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
@@ -84,6 +86,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -101,6 +105,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -119,6 +124,7 @@ const { t, n } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
@@ -144,6 +150,18 @@ const queueHistoryTooltipConfig = computed(() =>
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
)
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||
class: '*:text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
@@ -170,6 +188,19 @@ const toggleQueueOverlay = () => {
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const showQueueContextMenu = (event: MouseEvent) => {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { Mock } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -151,8 +152,8 @@ describe('BaseTerminal', () => {
|
||||
// Trigger the selection change callback that was registered during mount
|
||||
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
|
||||
// Access the mock calls - TypeScript can't infer the mock structure dynamically
|
||||
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
|
||||
.calls[0][0]
|
||||
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
|
||||
const selectionCallback = mockCalls[0][0] as () => void
|
||||
selectionCallback()
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createApp } from 'vue'
|
||||
import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
import FormRadioGroup from './FormRadioGroup.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
describe('FormRadioGroup', () => {
|
||||
beforeAll(() => {
|
||||
@@ -14,7 +15,8 @@ describe('FormRadioGroup', () => {
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any, options = {}) => {
|
||||
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
|
||||
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
|
||||
return mount(FormRadioGroup, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
@@ -92,9 +94,9 @@ describe('FormRadioGroup', () => {
|
||||
|
||||
it('handles custom object with optionLabel and optionValue', () => {
|
||||
const options = [
|
||||
{ name: 'First Option', id: 1 },
|
||||
{ name: 'Second Option', id: 2 },
|
||||
{ name: 'Third Option', id: 3 }
|
||||
{ name: 'First Option', id: '1' },
|
||||
{ name: 'Second Option', id: '2' },
|
||||
{ name: 'Third Option', id: '3' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
@@ -108,9 +110,9 @@ describe('FormRadioGroup', () => {
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe(1)
|
||||
expect(radioButtons[1].props('value')).toBe(2)
|
||||
expect(radioButtons[2].props('value')).toBe(3)
|
||||
expect(radioButtons[0].props('value')).toBe('1')
|
||||
expect(radioButtons[1].props('value')).toBe('2')
|
||||
expect(radioButtons[2].props('value')).toBe('3')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('First Option')
|
||||
@@ -167,10 +169,7 @@ describe('FormRadioGroup', () => {
|
||||
})
|
||||
|
||||
it('handles object with missing properties gracefully', () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', val: 'opt1' },
|
||||
{ text: 'Option 2', value: 'opt2' }
|
||||
]
|
||||
const options = [{ label: 'Option 1', val: 'opt1' }]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
@@ -179,11 +178,10 @@ describe('FormRadioGroup', () => {
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(2)
|
||||
expect(radioButtons).toHaveLength(1)
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Unknown')
|
||||
expect(labels[1].text()).toBe('Option 2')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any
|
||||
options: (SettingOption | string)[]
|
||||
options?: (string | SettingOption | Record<string, string>)[]
|
||||
optionLabel?: string
|
||||
optionValue?: string
|
||||
id?: string
|
||||
|
||||
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
|
||||
import UrlInput from './UrlInput.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
describe('UrlInput', () => {
|
||||
beforeEach(() => {
|
||||
@@ -14,7 +15,13 @@ describe('UrlInput', () => {
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any, options = {}) => {
|
||||
const mountComponent = (
|
||||
props: ComponentProps<typeof UrlInput> & {
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
},
|
||||
options = {}
|
||||
) => {
|
||||
return mount(UrlInput, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
@@ -169,25 +176,25 @@ describe('UrlInput', () => {
|
||||
await input.setValue(' https://leading-space.com')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
|
||||
expect(input.element.value).toBe('https://leading-space.com')
|
||||
|
||||
// Test trailing whitespace
|
||||
await input.setValue('https://trailing-space.com ')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://trailing-space.com')
|
||||
expect(input.element.value).toBe('https://trailing-space.com')
|
||||
|
||||
// Test both leading and trailing whitespace
|
||||
await input.setValue(' https://both-spaces.com ')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://both-spaces.com')
|
||||
expect(input.element.value).toBe('https://both-spaces.com')
|
||||
|
||||
// Test whitespace in the middle of the URL
|
||||
await input.setValue('https:// middle-space.com')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://middle-space.com')
|
||||
expect(input.element.value).toBe('https://middle-space.com')
|
||||
})
|
||||
|
||||
it('trims whitespace when value set externally', async () => {
|
||||
@@ -196,15 +203,17 @@ describe('UrlInput', () => {
|
||||
placeholder: 'Enter URL'
|
||||
})
|
||||
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Check initial value is trimmed
|
||||
expect(wrapper.vm.internalValue).toBe('https://initial-value.com')
|
||||
expect(input.element.value).toBe('https://initial-value.com')
|
||||
|
||||
// Update props with whitespace
|
||||
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
|
||||
await nextTick()
|
||||
|
||||
// Check updated value is trimmed
|
||||
expect(wrapper.vm.internalValue).toBe('https://updated-value.com')
|
||||
expect(input.element.value).toBe('https://updated-value.com')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Avatar from 'primevue/avatar'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -27,7 +29,7 @@ describe('UserAvatar', () => {
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any = {}) => {
|
||||
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
|
||||
return mount(UserAvatar, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
|
||||
43
src/components/common/WorkspaceProfilePic.vue
Normal file
43
src/components/common/WorkspaceProfilePic.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md text-base font-semibold text-white"
|
||||
:style="{
|
||||
background: gradient,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
}"
|
||||
>
|
||||
{{ letter }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { workspaceName } = defineProps<{
|
||||
workspaceName: string
|
||||
}>()
|
||||
|
||||
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
|
||||
|
||||
const gradient = computed(() => {
|
||||
const seed = letter.value.charCodeAt(0)
|
||||
|
||||
function mulberry32(a: number) {
|
||||
return function () {
|
||||
let t = (a += 0x6d2b79f5)
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const rand = mulberry32(seed)
|
||||
|
||||
const hue1 = Math.floor(rand() * 360)
|
||||
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
|
||||
const sat = 65 + Math.floor(rand() * 20)
|
||||
const light = 55 + Math.floor(rand() * 15)
|
||||
|
||||
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
|
||||
})
|
||||
</script>
|
||||
@@ -4,7 +4,12 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="item.dialogComponentProps.pt"
|
||||
:aria-labelledby="item.key"
|
||||
@@ -38,7 +43,15 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
</script>
|
||||
@@ -55,4 +68,27 @@ const dialogStore = useDialogStore()
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
.settings-dialog-workspace {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
}
|
||||
|
||||
.settings-dialog-workspace .p-dialog-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({
|
||||
}))
|
||||
|
||||
describe('SettingItem', () => {
|
||||
const mountComponent = (props: any, options = {}): any => {
|
||||
const mountComponent = (props: Record<string, unknown>, options = {}) => {
|
||||
return mount(SettingItem, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
@@ -32,6 +32,7 @@ describe('SettingItem', () => {
|
||||
'i-material-symbols:experiment-outline': true
|
||||
}
|
||||
},
|
||||
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
|
||||
props,
|
||||
...options
|
||||
})
|
||||
@@ -48,8 +49,9 @@ describe('SettingItem', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Get the options property of the FormItem
|
||||
const options = wrapper.vm.formItem.options
|
||||
// Check the FormItem component's item prop for the options
|
||||
const formItem = wrapper.findComponent({ name: 'FormItem' })
|
||||
const options = formItem.props('item').options
|
||||
expect(options).toEqual([
|
||||
{ text: 'Correctly Translated', value: 'Correctly Translated' }
|
||||
])
|
||||
@@ -67,7 +69,8 @@ describe('SettingItem', () => {
|
||||
})
|
||||
|
||||
// Should not throw an error and tooltip should be preserved as-is
|
||||
expect(wrapper.vm.formItem.tooltip).toBe(
|
||||
const formItem = wrapper.findComponent({ name: 'FormItem' })
|
||||
expect(formItem.props('item').tooltip).toBe(
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
import UsageLogsTable from './UsageLogsTable.vue'
|
||||
@@ -19,7 +20,7 @@ import UsageLogsTable from './UsageLogsTable.vue'
|
||||
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
events: any[]
|
||||
events: Partial<AuditLog>[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
|
||||
11
src/components/dialog/content/setting/WorkspacePanel.vue
Normal file
11
src/components/dialog/content/setting/WorkspacePanel.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<TabPanel value="Workspace" class="h-full">
|
||||
<WorkspacePanelContent />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
|
||||
</script>
|
||||
163
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
163
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="pb-8 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 !text-3xl"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</div>
|
||||
<Tabs :value="activeTab" @update:value="setActiveTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabList class="w-full">
|
||||
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab>
|
||||
</TabList>
|
||||
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
item.class,
|
||||
item.disabled ? 'pointer-events-auto' : ''
|
||||
]"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
item
|
||||
})
|
||||
"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContent />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName, isWorkspaceSubscribed } = storeToRefs(workspaceStore)
|
||||
|
||||
const { activeTab, setActiveTab, permissions, uiConfig } = useWorkspaceUI()
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
function handleLeaveWorkspace() {
|
||||
showLeaveWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleDeleteWorkspace() {
|
||||
showDeleteWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleEditWorkspace() {
|
||||
showEditWorkspaceDialog()
|
||||
}
|
||||
|
||||
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
||||
// Use workspace's own subscription status, not the global isActiveSubscription
|
||||
const isDeleteDisabled = computed(
|
||||
() =>
|
||||
uiConfig.value.workspaceMenuAction === 'delete' &&
|
||||
isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
const deleteTooltip = computed(() => {
|
||||
if (!isDeleteDisabled.value) return null
|
||||
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
|
||||
return tooltipKey ? t(tooltipKey) : null
|
||||
})
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
// Add edit option for owners
|
||||
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: handleEditWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
const action = uiConfig.value.workspaceMenuAction
|
||||
if (action === 'delete') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
icon: 'pi pi-trash',
|
||||
class: isDeleteDisabled.value
|
||||
? 'text-danger/50 cursor-not-allowed'
|
||||
: 'text-danger',
|
||||
disabled: isDeleteDisabled.value,
|
||||
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
|
||||
})
|
||||
} else if (action === 'leave') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: handleLeaveWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setActiveTab(defaultTab)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
|
||||
<span>{{ workspaceName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
</script>
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -63,7 +65,7 @@ describe('ApiKeyForm', () => {
|
||||
mockLoading.mockReset()
|
||||
})
|
||||
|
||||
const mountComponent = (props: any = {}) => {
|
||||
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
|
||||
return mount(ApiKeyForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
|
||||
@@ -112,8 +112,10 @@ describe('SignInForm', () => {
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Click forgot password link while email is empty
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
@@ -138,7 +140,10 @@ describe('SignInForm', () => {
|
||||
|
||||
it('calls handleForgotPassword with email when link is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Spy on handleForgotPassword
|
||||
const handleForgotPasswordSpy = vi.spyOn(
|
||||
@@ -161,7 +166,10 @@ describe('SignInForm', () => {
|
||||
describe('Form Submission', () => {
|
||||
it('emits submit event when onSubmit is called with valid data', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Call onSubmit directly with valid data
|
||||
component.onSubmit({
|
||||
@@ -181,7 +189,10 @@ describe('SignInForm', () => {
|
||||
|
||||
it('does not emit submit event when form is invalid', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Call onSubmit with invalid form
|
||||
component.onSubmit({ valid: false, values: {} })
|
||||
@@ -254,12 +265,17 @@ describe('SignInForm', () => {
|
||||
describe('Focus Behavior', () => {
|
||||
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Call handleForgotPassword with no email
|
||||
await component.handleForgotPassword('', false)
|
||||
@@ -273,12 +289,17 @@ describe('SignInForm', () => {
|
||||
|
||||
it('does not focus email input when valid email is provided', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Mock getElementById
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Call handleForgotPassword with valid email
|
||||
await component.handleForgotPassword('test@example.com', true)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="workspaceName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
:placeholder="
|
||||
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
|
||||
"
|
||||
@keydown.enter="isValidName && onCreate()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onCreate"
|
||||
>
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const workspaceName = ref('')
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
// Allow alphanumeric, spaces, hyphens, underscores (safe characters)
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const name = workspaceName.value.trim()
|
||||
// Call optional callback if provided
|
||||
await onConfirm?.(name)
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
// Create workspace and switch to it (triggers reload internally)
|
||||
await workspaceStore.createWorkspace(name)
|
||||
} catch (error) {
|
||||
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.deleteDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
workspaceName
|
||||
? $t('workspacePanel.deleteDialog.messageWithName', {
|
||||
name: workspaceName
|
||||
})
|
||||
: $t('workspacePanel.deleteDialog.message')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onDelete">
|
||||
{{ $t('g.delete') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { workspaceId, workspaceName } = defineProps<{
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Delete workspace (uses workspaceId if provided, otherwise current workspace)
|
||||
await workspaceStore.deleteWorkspace(workspaceId)
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[DeleteWorkspaceDialog] Failed to delete workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newWorkspaceName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
@keydown.enter="isValidName && onSave()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const newWorkspaceName = ref(workspaceStore.workspaceName)
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = newWorkspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.updateWorkspaceName(newWorkspaceName.value.trim())
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.toast.workspaceUpdated.title'),
|
||||
detail: t('workspacePanel.toast.workspaceUpdated.message'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[EditWorkspaceDialog] Failed to update workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.leaveDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.leaveDialog.message') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onLeave">
|
||||
{{ $t('workspacePanel.leaveDialog.leave') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
}
|
||||
|
||||
async function onLeave() {
|
||||
loading.value = true
|
||||
try {
|
||||
// leaveWorkspace() handles switching to personal workspace internally and reloads
|
||||
await workspaceStore.leaveWorkspace()
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[LeaveWorkspaceDialog] Failed to leave workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- A button that shows current authenticated user's avatar -->
|
||||
<!-- A button that shows workspace icon (Cloud) or user avatar -->
|
||||
<template>
|
||||
<div>
|
||||
<Button
|
||||
@@ -16,7 +16,16 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
|
||||
<WorkspaceProfilePic
|
||||
v-if="showWorkspaceIcon"
|
||||
:workspace-name="workspaceName"
|
||||
:class="compact && 'size-full'"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-else
|
||||
:photo-url="photoURL"
|
||||
:class="compact && 'size-full'"
|
||||
/>
|
||||
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||
</div>
|
||||
@@ -27,38 +36,65 @@
|
||||
:show-arrow="false"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'rounded-lg'
|
||||
class: 'rounded-lg w-80'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<CurrentUserPopover @close="closePopover" />
|
||||
<!-- Workspace mode: workspace-aware popover -->
|
||||
<CurrentUserPopoverWorkspace
|
||||
v-if="teamWorkspacesEnabled"
|
||||
@close="closePopover"
|
||||
/>
|
||||
<!-- Legacy mode: original popover -->
|
||||
<CurrentUserPopover v-else @close="closePopover" />
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||
)
|
||||
|
||||
const { showArrow = true, compact = false } = defineProps<{
|
||||
showArrow?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
|
||||
|
||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const showWorkspaceIcon = computed(() => isCloud && teamWorkspacesEnabled.value)
|
||||
|
||||
const workspaceName = computed(() => {
|
||||
if (!showWorkspaceIcon.value) return ''
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
return workspaceName.value
|
||||
})
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const closePopover = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
337
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
337
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<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="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-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>
|
||||
|
||||
<!-- Workspace Selector -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
|
||||
@click="toggleWorkspaceSwitcher"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 shrink-0 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<span class="truncate text-sm text-base-foreground">{{
|
||||
workspaceName
|
||||
}}</span>
|
||||
<div
|
||||
v-if="workspaceTierName"
|
||||
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{{ workspaceTierName }}
|
||||
</div>
|
||||
<span v-else class="shrink-0 text-xs text-muted-foreground">
|
||||
{{ $t('workspaceSwitcher.subscribe') }}
|
||||
</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="workspaceSwitcherPopover"
|
||||
append-to="body"
|
||||
:pt="{
|
||||
content: {
|
||||
class: 'p-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<WorkspaceSwitcherPopover
|
||||
@select="workspaceSwitcherPopover?.hide()"
|
||||
@create="handleCreateWorkspace"
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<!-- Credits Section (PERSONAL and OWNER only) -->
|
||||
<template v-if="showCreditsSection">
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
displayedCredits
|
||||
}}</span>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<!-- Subscribed: Show Add Credits button -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && isWorkspaceSubscribed"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<!-- Unsubscribed: Show Subscribe button (disabled until billing is ready) -->
|
||||
<SubscribeButton
|
||||
v-else
|
||||
disabled
|
||||
:fluid="false"
|
||||
:label="$t('workspaceSwitcher.subscribe')"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
</template>
|
||||
|
||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||
<div
|
||||
v-if="showPlansAndPricing"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="plans-pricing-menu-item"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.plansAndPricing')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="canUpgrade"
|
||||
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
|
||||
>
|
||||
{{ $t('subscription.upgrade') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
|
||||
<div
|
||||
v-if="showManagePlan"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="manage-plan-menu-item"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
>
|
||||
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.managePlan')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Partner Nodes Pricing (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
>
|
||||
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.partnerNodesCredits')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Workspace Settings (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="workspace-settings-menu-item"
|
||||
@click="handleOpenWorkspaceSettings"
|
||||
>
|
||||
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('userSettings.workspaceSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Account Settings (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="user-settings-menu-item"
|
||||
@click="handleOpenUserSettings"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('userSettings.accountSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Logout (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="logout-menu-item"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('auth.signOut.signOut')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
isInPersonalWorkspace: isPersonalWorkspace,
|
||||
isWorkspaceSubscribed,
|
||||
subscriptionPlan
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { workspaceRole } = useWorkspaceUI()
|
||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { t } = useI18n()
|
||||
|
||||
const displayedCredits = computed(() =>
|
||||
isWorkspaceSubscribed.value ? totalCredits.value : '0'
|
||||
)
|
||||
|
||||
// Workspace subscription tier name (not user tier)
|
||||
const workspaceTierName = computed(() => {
|
||||
if (!isWorkspaceSubscribed.value) return null
|
||||
if (!subscriptionPlan.value) return null
|
||||
// Convert plan to display name
|
||||
if (subscriptionPlan.value === 'PRO_MONTHLY')
|
||||
return t('subscription.tiers.pro.name')
|
||||
if (subscriptionPlan.value === 'PRO_YEARLY')
|
||||
return t('subscription.tierNameYearly', {
|
||||
name: t('subscription.tiers.pro.name')
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
// PRO is currently the only/highest tier, so no upgrades available
|
||||
// This will need updating when additional tiers are added
|
||||
return false
|
||||
})
|
||||
|
||||
const showPlansAndPricing = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
const showManagePlan = computed(
|
||||
() => showPlansAndPricing.value && isActiveSubscription.value
|
||||
)
|
||||
const showCreditsSection = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenWorkspaceSettings = () => {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.show()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else {
|
||||
dialogService.showSettingsDialog('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await handleSignOut()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
workspaceSwitcherPopover.value?.hide()
|
||||
dialogService.showCreateWorkspaceDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||
workspaceSwitcherPopover.value?.toggle(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
</script>
|
||||
166
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
166
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<!-- Loading state -->
|
||||
<div v-if="isFetchingWorkspaces" class="flex flex-col gap-2 p-2">
|
||||
<div
|
||||
v-for="i in 2"
|
||||
:key="i"
|
||||
class="flex h-[54px] animate-pulse items-center gap-2 rounded px-2 py-4"
|
||||
>
|
||||
<div class="size-8 rounded-full bg-secondary-background" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="h-4 w-24 rounded bg-secondary-background" />
|
||||
<div class="h-3 w-16 rounded bg-secondary-background" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace list -->
|
||||
<template v-else>
|
||||
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
|
||||
<div class="border-b border-border-default p-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group flex h-[54px] w-full items-center gap-2 rounded px-2 py-4',
|
||||
'hover:bg-secondary-background-hover',
|
||||
isCurrentWorkspace(workspace) && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
|
||||
@click="handleSelectWorkspace(workspace)"
|
||||
>
|
||||
<WorkspaceProfilePic
|
||||
class="size-8 text-sm"
|
||||
:workspace-name="workspace.name"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ workspace.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="workspace.type !== 'personal'"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ getRoleLabel(workspace.role) }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="isCurrentWorkspace(workspace)"
|
||||
class="pi pi-check text-sm text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- <Divider class="mx-0 my-0" /> -->
|
||||
|
||||
<!-- Create workspace button -->
|
||||
<div class="px-2 py-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
|
||||
canCreateWorkspace
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'cursor-default'
|
||||
)
|
||||
"
|
||||
@click="canCreateWorkspace && handleCreateWorkspace()"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
|
||||
!canCreateWorkspace && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="pi pi-plus text-sm text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
v-if="canCreateWorkspace"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.createWorkspace') }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import type {
|
||||
WorkspaceRole,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface AvailableWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
role: WorkspaceRole
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [workspace: AvailableWorkspace]
|
||||
create: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
|
||||
storeToRefs(workspaceStore)
|
||||
|
||||
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
|
||||
workspaces.value.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
type: w.type,
|
||||
role: w.role
|
||||
}))
|
||||
)
|
||||
|
||||
function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
|
||||
return workspace.id === workspaceId.value
|
||||
}
|
||||
|
||||
function getRoleLabel(role: AvailableWorkspace['role']): string {
|
||||
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
|
||||
if (role === 'member') return t('workspaceSwitcher.roleMember')
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
const success = await switchWithConfirmation(workspace.id)
|
||||
if (success) {
|
||||
emit('select', workspace)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateWorkspace() {
|
||||
emit('create')
|
||||
}
|
||||
</script>
|
||||
@@ -26,7 +26,8 @@ export const buttonVariants = cva({
|
||||
md: 'h-8 rounded-lg p-2 text-xs',
|
||||
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
|
||||
icon: 'size-8',
|
||||
'icon-sm': 'size-5 p-0'
|
||||
'icon-sm': 'size-5 p-0',
|
||||
unset: ''
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ onClickOutside(rootEl, () => {
|
||||
<i
|
||||
v-if="!disabled && !isEditing"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
|
||||
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
|
||||
/>
|
||||
</TagsInputRoot>
|
||||
</template>
|
||||
|
||||
@@ -73,6 +73,14 @@ export const useNodeBadge = () => {
|
||||
onMounted(() => {
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
watch(
|
||||
() => nodePricing.pricingRevision.value,
|
||||
() => {
|
||||
if (!showApiPricingBadge.value) return
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
extensionStore.registerExtension({
|
||||
name: 'Comfy.NodeBadge',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
@@ -111,17 +119,16 @@ export const useNodeBadge = () => {
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
// Get the pricing function to determine if this node has dynamic pricing
|
||||
// JSONata rules are dynamic if they depend on any widgets/inputs/input_groups
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
typeof pricingConfig?.displayPrice === 'function'
|
||||
|
||||
let creditsBadge
|
||||
const createBadge = () => {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
return priceBadge.getCreditsBadge(price)
|
||||
}
|
||||
!!pricingConfig &&
|
||||
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
|
||||
|
||||
// Keep the existing widget-watch wiring ONLY to trigger redraws on widget change.
|
||||
// (We no longer rely on it to hold the current badge value.)
|
||||
if (hasDynamicPricing) {
|
||||
// For dynamic pricing nodes, use computed that watches widget changes
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
@@ -133,13 +140,63 @@ export const useNodeBadge = () => {
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
creditsBadge = computedWithWidgetWatch(createBadge)
|
||||
} else {
|
||||
// For static pricing nodes, use regular computed
|
||||
creditsBadge = computed(createBadge)
|
||||
// Ensure watchers are installed; ignore the returned value.
|
||||
// (This call is what registers the widget listeners in most implementations.)
|
||||
computedWithWidgetWatch(() => 0)
|
||||
|
||||
// Hook into connection changes to trigger price recalculation
|
||||
// This handles both connect and disconnect in VueNodes mode
|
||||
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
|
||||
const inputGroupPrefixes =
|
||||
pricingConfig?.depends_on?.input_groups ?? []
|
||||
const hasRelevantInputs =
|
||||
relevantInputs.length > 0 || inputGroupPrefixes.length > 0
|
||||
|
||||
if (hasRelevantInputs) {
|
||||
const originalOnConnectionsChange = node.onConnectionsChange
|
||||
node.onConnectionsChange = function (
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
) {
|
||||
originalOnConnectionsChange?.call(
|
||||
this,
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
)
|
||||
// Only trigger if this input affects pricing
|
||||
const inputName = ioSlot?.name
|
||||
if (!inputName) return
|
||||
const isRelevantInput =
|
||||
relevantInputs.includes(inputName) ||
|
||||
inputGroupPrefixes.some((prefix) =>
|
||||
inputName.startsWith(prefix + '.')
|
||||
)
|
||||
if (isRelevantInput) {
|
||||
nodePricing.triggerPriceRecalculation(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
let lastLabel = nodePricing.getNodeDisplayPrice(node)
|
||||
let lastBadge = priceBadge.getCreditsBadge(lastLabel)
|
||||
|
||||
const creditsBadgeGetter: () => LGraphBadge = () => {
|
||||
const label = nodePricing.getNodeDisplayPrice(node)
|
||||
if (label !== lastLabel) {
|
||||
lastLabel = label
|
||||
lastBadge = priceBadge.getCreditsBadge(label)
|
||||
}
|
||||
return lastBadge
|
||||
}
|
||||
|
||||
node.badges.push(creditsBadgeGetter)
|
||||
}
|
||||
},
|
||||
init() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,17 @@
|
||||
import { t } from '@/i18n'
|
||||
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
// Zendesk feedback URL - update this with the actual URL
|
||||
const ZENDESK_FEEDBACK_URL =
|
||||
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=43066738713236'
|
||||
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
|
||||
|
||||
const distribution = getDistribution()
|
||||
const params = new URLSearchParams({
|
||||
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
|
||||
})
|
||||
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
@@ -12,7 +19,7 @@ const buttons: ActionBarButton[] = [
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
window.open(ZENDESK_FEEDBACK_URL, '_blank', 'noopener,noreferrer')
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -32,13 +32,17 @@ if (isCloud) {
|
||||
await import('./cloudRemoteConfig')
|
||||
await import('./cloudBadges')
|
||||
await import('./cloudSessionCookie')
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
if (window.__CONFIG__?.subscription_required) {
|
||||
await import('./cloudSubscription')
|
||||
}
|
||||
}
|
||||
|
||||
// Feedback button for cloud and nightly builds
|
||||
if (isCloud || isNightly) {
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
}
|
||||
|
||||
// Nightly-only extensions
|
||||
if (isNightly && !isCloud) {
|
||||
await import('./nightlyBadges')
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"reportIssueTooltip": "Submit the error report to Comfy Org",
|
||||
"reportSent": "Report Submitted",
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
"copyAll": "Copy All",
|
||||
"openNewIssue": "Open New Issue",
|
||||
"showReport": "Show Report",
|
||||
"imageFailedToLoad": "Image failed to load",
|
||||
@@ -1297,7 +1298,10 @@
|
||||
"VueNodes": "Nodes 2.0",
|
||||
"Nodes 2_0": "Nodes 2.0",
|
||||
"Execution": "Execution",
|
||||
"PLY": "PLY"
|
||||
"PLY": "PLY",
|
||||
"Workspace": "Workspace",
|
||||
"General": "General",
|
||||
"Other": "Other"
|
||||
},
|
||||
"serverConfigItems": {
|
||||
"listen": {
|
||||
@@ -2021,6 +2025,8 @@
|
||||
"renewsDate": "Renews {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"managePayment": "Manage Payment",
|
||||
"cancelSubscription": "Cancel Subscription",
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
"partnerNodesDescription": "For running commercial/proprietary models",
|
||||
"totalCredits": "Total credits",
|
||||
@@ -2075,6 +2081,9 @@
|
||||
"subscribeToRunFull": "Subscribe to Run",
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||
"workspaceNotSubscribed": "This workspace is not on a subscription",
|
||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
|
||||
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
|
||||
"description": "Choose the best plan for you",
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
@@ -2110,12 +2119,64 @@
|
||||
"userSettings": {
|
||||
"title": "My Account Settings",
|
||||
"accountSettings": "Account settings",
|
||||
"workspaceSettings": "Workspace settings",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"provider": "Sign-in Provider",
|
||||
"notSet": "Not set",
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"tabs": {
|
||||
"planCredits": "Plan & Credits"
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
"nameLabel": "Workspace name",
|
||||
"save": "Save"
|
||||
},
|
||||
"leaveDialog": {
|
||||
"title": "Leave this workspace?",
|
||||
"message": "You won't be able to join again unless you contact the workspace owner.",
|
||||
"leave": "Leave"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete this workspace?",
|
||||
"message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.",
|
||||
"messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone."
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"title": "Create a new workspace",
|
||||
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
|
||||
"nameLabel": "Workspace name*",
|
||||
"namePlaceholder": "Enter workspace name",
|
||||
"create": "Create"
|
||||
},
|
||||
"toast": {
|
||||
"workspaceUpdated": {
|
||||
"title": "Workspace updated",
|
||||
"message": "Workspace details have been saved."
|
||||
},
|
||||
"failedToUpdateWorkspace": "Failed to update workspace",
|
||||
"failedToCreateWorkspace": "Failed to create workspace",
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
"failedToLeaveWorkspace": "Failed to leave workspace"
|
||||
}
|
||||
},
|
||||
"workspaceSwitcher": {
|
||||
"switchWorkspace": "Switch workspace",
|
||||
"subscribe": "Subscribe",
|
||||
"roleOwner": "Owner",
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create new workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one."
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
|
||||
@@ -2431,6 +2492,7 @@
|
||||
"selectModelPrompt": "Select a model to see its information",
|
||||
"basicInfo": "Basic Info",
|
||||
"displayName": "Display Name",
|
||||
"editDisplayName": "Edit display name",
|
||||
"fileName": "File Name",
|
||||
"source": "Source",
|
||||
"viewOnSource": "View on {source}",
|
||||
@@ -2678,4 +2740,4 @@
|
||||
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1 px-4 py-2 text-sm text-muted-foreground">
|
||||
<span>{{ label }}</span>
|
||||
<div class="flex flex-col gap-2 px-4 py-2 text-sm text-base-foreground">
|
||||
<div class="flex items-center justify-between relative">
|
||||
<span>{{ label }}</span>
|
||||
<slot name="label-action" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
|
||||
import ModelInfoPanel from './ModelInfoPanel.vue'
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({
|
||||
copyToClipboard: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
|
||||
@@ -10,17 +10,29 @@
|
||||
</span>
|
||||
</template>
|
||||
<ModelInfoField :label="t('assetBrowser.modelInfo.displayName')">
|
||||
<EditableText
|
||||
:model-value="displayName"
|
||||
:is-editing="isEditingDisplayName"
|
||||
:class="cn('break-all', !isImmutable && 'text-base-foreground')"
|
||||
@dblclick="isEditingDisplayName = !isImmutable"
|
||||
@edit="handleDisplayNameEdit"
|
||||
@cancel="isEditingDisplayName = false"
|
||||
/>
|
||||
<div class="group flex justify-between">
|
||||
<EditableText
|
||||
:model-value="displayName"
|
||||
:is-editing="isEditingDisplayName"
|
||||
:class="cn('break-all text-muted-foreground flex-auto')"
|
||||
@dblclick="isEditingDisplayName = !isImmutable"
|
||||
@edit="handleDisplayNameEdit"
|
||||
@cancel="isEditingDisplayName = false"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isImmutable && !isEditingDisplayName"
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
class="transition-opacity opacity-0 group-hover:opacity-100"
|
||||
:aria-label="t('assetBrowser.modelInfo.editDisplayName')"
|
||||
@click="isEditingDisplayName = !isImmutable"
|
||||
>
|
||||
<i class="icon-[lucide--square-pen] self-center size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
|
||||
<span class="break-all">{{ asset.name }}</span>
|
||||
<span class="break-all text-muted-foreground">{{ asset.name }}</span>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField
|
||||
v-if="sourceUrl"
|
||||
@@ -51,7 +63,7 @@
|
||||
</span>
|
||||
</template>
|
||||
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
|
||||
<Select v-model="selectedModelType" :disabled="isImmutable">
|
||||
<Select v-if="!isImmutable" v-model="selectedModelType">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue
|
||||
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
|
||||
@@ -67,6 +79,12 @@
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else class="p-2 text-sm text-muted-foreground">
|
||||
{{
|
||||
modelTypes.find((o) => o.value === selectedModelType)?.name ??
|
||||
t('assetBrowser.unknown')
|
||||
}}
|
||||
</div>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField :label="t('assetBrowser.modelInfo.compatibleBaseModels')">
|
||||
<TagsInput
|
||||
@@ -124,14 +142,31 @@
|
||||
v-if="triggerPhrases.length > 0"
|
||||
:label="t('assetBrowser.modelInfo.triggerPhrases')"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
<template #label-action>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:title="t('g.copyAll')"
|
||||
:aria-label="t('g.copyAll')"
|
||||
class="p-0"
|
||||
@click="copyToClipboard(triggerPhrases.join(', '))"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4 min-w-4 min-h-4 opacity-60" />
|
||||
</Button>
|
||||
</template>
|
||||
<div class="flex flex-wrap gap-1 pt-1">
|
||||
<Button
|
||||
v-for="phrase in triggerPhrases"
|
||||
:key="phrase"
|
||||
class="rounded px-2 py-0.5 text-xs"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:title="t('g.copyToClipboard')"
|
||||
class="text-pretty whitespace-normal text-left text-xs"
|
||||
@click="copyToClipboard(phrase)"
|
||||
>
|
||||
{{ phrase }}
|
||||
</span>
|
||||
<i class="icon-[lucide--copy] size-4 min-w-4 min-h-4 opacity-60" />
|
||||
</Button>
|
||||
</div>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField
|
||||
@@ -170,7 +205,9 @@ import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
@@ -201,6 +238,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import ModelInfoField from './ModelInfoField.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const descriptionTextarea = useTemplateRef<HTMLTextAreaElement>(
|
||||
'descriptionTextarea'
|
||||
@@ -219,6 +257,7 @@ const assetsStore = useAssetsStore()
|
||||
const { modelTypes } = useModelTypes()
|
||||
|
||||
const pendingUpdates = ref<AssetUserMetadata>({})
|
||||
const pendingModelType = ref<string | undefined>(undefined)
|
||||
const isEditingDisplayName = ref(false)
|
||||
|
||||
const isImmutable = computed(() => asset.is_immutable ?? true)
|
||||
@@ -239,10 +278,17 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => asset.tags,
|
||||
() => {
|
||||
pendingModelType.value = undefined
|
||||
}
|
||||
)
|
||||
|
||||
const debouncedFlushMetadata = useDebounceFn(() => {
|
||||
if (isImmutable.value) return
|
||||
assetsStore.updateAssetMetadata(
|
||||
asset.id,
|
||||
asset,
|
||||
{ ...(asset.user_metadata ?? {}), ...pendingUpdates.value },
|
||||
cacheKey
|
||||
)
|
||||
@@ -267,7 +313,7 @@ const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
|
||||
const newTags = asset.tags
|
||||
.filter((tag) => tag !== currentModelType)
|
||||
.concat(newModelType)
|
||||
assetsStore.updateAssetTags(asset.id, newTags, cacheKey)
|
||||
assetsStore.updateAssetTags(asset, newTags, cacheKey)
|
||||
}, 500)
|
||||
|
||||
const baseModels = computed({
|
||||
@@ -288,9 +334,11 @@ const userDescription = computed({
|
||||
})
|
||||
|
||||
const selectedModelType = computed({
|
||||
get: () => getAssetModelType(asset) ?? undefined,
|
||||
get: () => pendingModelType.value ?? getAssetModelType(asset) ?? undefined,
|
||||
set: (value: string | undefined) => {
|
||||
if (value) debouncedSaveModelType(value)
|
||||
if (!value) return
|
||||
pendingModelType.value = value
|
||||
debouncedSaveModelType(value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -106,6 +106,16 @@ const zAssetUserMetadata = z.object({
|
||||
|
||||
export type AssetUserMetadata = z.infer<typeof zAssetUserMetadata>
|
||||
|
||||
export const tagsOperationResultSchema = z.object({
|
||||
total_tags: z.array(z.string()),
|
||||
added: z.array(z.string()).optional(),
|
||||
removed: z.array(z.string()).optional(),
|
||||
already_present: z.array(z.string()).optional(),
|
||||
not_present: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export type TagsOperationResult = z.infer<typeof tagsOperationResultSchema>
|
||||
|
||||
// Legacy interface for backward compatibility (now aligned with Zod schema)
|
||||
export interface ModelFolderInfo {
|
||||
name: string
|
||||
|
||||
@@ -5,7 +5,8 @@ import { st } from '@/i18n'
|
||||
import {
|
||||
assetItemSchema,
|
||||
assetResponseSchema,
|
||||
asyncUploadResponseSchema
|
||||
asyncUploadResponseSchema,
|
||||
tagsOperationResultSchema
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetItem,
|
||||
@@ -14,7 +15,8 @@ import type {
|
||||
AssetUpdatePayload,
|
||||
AsyncUploadResponse,
|
||||
ModelFile,
|
||||
ModelFolder
|
||||
ModelFolder,
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
@@ -471,6 +473,66 @@ function createAssetService() {
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tags to an asset
|
||||
* @param id - The asset ID (UUID)
|
||||
* @param tags - Tags to add
|
||||
* @returns Promise<TagsOperationResult>
|
||||
*/
|
||||
async function addAssetTags(
|
||||
id: string,
|
||||
tags: string[]
|
||||
): Promise<TagsOperationResult> {
|
||||
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}/tags`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags })
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to add tags to asset ${id}: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await res.json()
|
||||
const parseResult = tagsOperationResultSchema.safeParse(result)
|
||||
if (!parseResult.success) {
|
||||
throw fromZodError(parseResult.error)
|
||||
}
|
||||
return parseResult.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tags from an asset
|
||||
* @param id - The asset ID (UUID)
|
||||
* @param tags - Tags to remove
|
||||
* @returns Promise<TagsOperationResult>
|
||||
*/
|
||||
async function removeAssetTags(
|
||||
id: string,
|
||||
tags: string[]
|
||||
): Promise<TagsOperationResult> {
|
||||
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}/tags`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags })
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to remove tags from asset ${id}: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await res.json()
|
||||
const parseResult = tagsOperationResultSchema.safeParse(result)
|
||||
if (!parseResult.success) {
|
||||
throw fromZodError(parseResult.error)
|
||||
}
|
||||
return parseResult.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an asset asynchronously using the /api/assets/download endpoint
|
||||
* Returns immediately with either the asset (if already exists) or a task to track
|
||||
@@ -546,6 +608,8 @@ function createAssetService() {
|
||||
getAssetsByTag,
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
addAssetTags,
|
||||
removeAssetTags,
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl,
|
||||
uploadAssetFromBase64,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Button
|
||||
:size
|
||||
:loading="isLoading"
|
||||
:disabled="isPolling"
|
||||
:disabled="disabled || isPolling"
|
||||
variant="primary"
|
||||
:style="
|
||||
variant === 'gradient'
|
||||
@@ -32,12 +32,14 @@ const {
|
||||
size = 'lg',
|
||||
fluid = true,
|
||||
variant = 'default',
|
||||
label
|
||||
label,
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
size?: 'sm' | 'lg'
|
||||
variant?: 'default' | 'gradient'
|
||||
fluid?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -17,208 +17,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grow overflow-auto">
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{
|
||||
$t('subscription.perMonth')
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('subscription.manageSubscription') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
|
||||
<SubscribeButton
|
||||
v-else
|
||||
:label="$t('subscription.subscribeNow')"
|
||||
size="sm"
|
||||
:fluid="false"
|
||||
class="text-xs"
|
||||
@subscribed="handleRefresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6 pt-9">
|
||||
<div class="flex flex-col shrink-0">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-modal-panel-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="8rem"
|
||||
height="2rem"
|
||||
/>
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ includedCreditsDisplay }}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ prepaidCredits }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
:href="usageHistoryUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<!-- Workspace mode: workspace-aware subscription content -->
|
||||
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
|
||||
<!-- Legacy mode: user-level subscription content -->
|
||||
<SubscriptionPanelContentLegacy v-else />
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-interface-stroke pt-3"
|
||||
@@ -265,171 +67,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.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 {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierFeatures,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue')
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
isYearlySubscription,
|
||||
handleInvoiceHistory
|
||||
} = useSubscription()
|
||||
const { isActiveSubscription, handleInvoiceHistory } = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
const usageHistoryUrl = computed(
|
||||
() => `${getComfyPlatformBaseUrl()}/profile/usage`
|
||||
)
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t('subscription.creditsRemainingThisYear', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
: t('subscription.creditsRemainingThisMonth', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
)
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t(`subscription.maxDuration.${key}`),
|
||||
label: t('subscription.maxDurationLabel')
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.gpuLabel')
|
||||
},
|
||||
{
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
]
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.customLoRAsLabel')
|
||||
})
|
||||
}
|
||||
|
||||
return benefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const {
|
||||
isLoadingSupport,
|
||||
handleAddApiCredits,
|
||||
handleMessageSupport,
|
||||
handleRefresh,
|
||||
handleLearnMoreClick
|
||||
} = useSubscriptionActions()
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
|
||||
useSubscriptionActions()
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
@@ -438,9 +101,3 @@ const handleOpenPartnerNodesInfo = () => {
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto">
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('subscription.manageSubscription') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
|
||||
<SubscribeButton
|
||||
v-if="!isActiveSubscription"
|
||||
:label="$t('subscription.subscribeNow')"
|
||||
size="sm"
|
||||
:fluid="false"
|
||||
class="text-xs"
|
||||
@subscribed="handleRefresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6 pt-9">
|
||||
<div class="flex flex-col shrink-0">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-modal-panel-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ includedCreditsDisplay }}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ prepaidCredits }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.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 {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierFeatures,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t('subscription.creditsRemainingThisYear', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
: t('subscription.creditsRemainingThisMonth', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
)
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t(`subscription.maxDuration.${key}`),
|
||||
label: t('subscription.maxDurationLabel')
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.gpuLabel')
|
||||
},
|
||||
{
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
]
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.customLoRAsLabel')
|
||||
})
|
||||
}
|
||||
|
||||
return benefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,435 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto">
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<!-- OWNER Unsubscribed State -->
|
||||
<template v-if="isOwnerUnsubscribed">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.subscriptionRequiredMessage') }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
|
||||
@click="handleSubscribeWorkspace"
|
||||
>
|
||||
{{ $t('subscription.subscribeNow') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- MEMBER View - read-only, no subscription data yet -->
|
||||
<template v-else-if="isMemberView">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.contactOwnerToSubscribe') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal Subscribed State (Owner with subscription) -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('subscription.managePayment') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="planMenu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6 pt-9">
|
||||
<div class="flex flex-col shrink-0">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-modal-panel-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ showZeroState ? '0' : totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0 / 0' : includedCreditsDisplay
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0' : prepaidCredits
|
||||
}}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription && !showZeroState"
|
||||
variant="secondary"
|
||||
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
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 {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierFeatures,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore)
|
||||
const { subscribeWorkspace } = workspaceStore
|
||||
const { permissions, workspaceRole } = useWorkspaceUI()
|
||||
const { t, n } = useI18n()
|
||||
|
||||
// OWNER with unsubscribed workspace - can see subscribe button
|
||||
const isOwnerUnsubscribed = computed(
|
||||
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
// MEMBER view - members can't manage subscription, show read-only zero state
|
||||
const isMemberView = computed(() => !permissions.value.canManageSubscription)
|
||||
|
||||
// Show zero state for credits (no real billing data yet)
|
||||
const showZeroState = computed(
|
||||
() => isOwnerUnsubscribed.value || isMemberView.value
|
||||
)
|
||||
|
||||
// Demo: Subscribe workspace to PRO monthly plan
|
||||
function handleSubscribeWorkspace() {
|
||||
subscribeWorkspace('PRO_MONTHLY')
|
||||
}
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
const planMenuItems = computed(() => [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t('subscription.creditsRemainingThisYear', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
: t('subscription.creditsRemainingThisMonth', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
)
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t(`subscription.maxDuration.${key}`),
|
||||
label: t('subscription.maxDurationLabel')
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.gpuLabel')
|
||||
},
|
||||
{
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
]
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.customLoRAsLabel')
|
||||
})
|
||||
}
|
||||
|
||||
return benefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -64,7 +64,7 @@ vi.mock('@/services/dialogService', () => ({
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: mockGetAuthHeader
|
||||
getFirebaseAuthHeader: mockGetAuthHeader
|
||||
})),
|
||||
FirebaseAuthStoreError: class extends Error {}
|
||||
}))
|
||||
|
||||
@@ -38,7 +38,7 @@ function useSubscriptionInternal() {
|
||||
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
||||
const { showSubscriptionRequiredDialog } = useDialogService()
|
||||
|
||||
const { getAuthHeader } = useFirebaseAuthStore()
|
||||
const { getFirebaseAuthHeader } = useFirebaseAuthStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
@@ -168,7 +168,7 @@ function useSubscriptionInternal() {
|
||||
* @returns Subscription status or null if no subscription exists
|
||||
*/
|
||||
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
|
||||
const authHeader = await getAuthHeader()
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
@@ -217,7 +217,7 @@ function useSubscriptionInternal() {
|
||||
|
||||
const initiateSubscriptionCheckout =
|
||||
async (): Promise<CloudSubscriptionCheckoutResponse> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(
|
||||
t('toastMessages.userNotAuthenticated')
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<ScrollPanel class="settings-sidebar w-48 shrink-0 p-2 2xl:w-64">
|
||||
<div
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'flex h-[80vh] w-full overflow-hidden'
|
||||
: 'settings-container'
|
||||
"
|
||||
>
|
||||
<ScrollPanel
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'w-48 shrink-0 p-2 2xl:w-64'
|
||||
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
|
||||
"
|
||||
>
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
class="settings-search-box mb-2 w-full"
|
||||
@@ -20,16 +32,40 @@
|
||||
(option: SettingTreeNode) =>
|
||||
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
|
||||
"
|
||||
class="w-full border-none"
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'w-full border-none bg-transparent'
|
||||
: 'w-full border-none'
|
||||
"
|
||||
>
|
||||
<template #optiongroup>
|
||||
<!-- Workspace mode: custom group headers -->
|
||||
<template v-if="teamWorkspacesEnabled" #optiongroup="{ option }">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
|
||||
{{ option.translatedLabel ?? option.label }}
|
||||
</h3>
|
||||
</template>
|
||||
<!-- Legacy mode: divider between groups -->
|
||||
<template v-else #optiongroup>
|
||||
<Divider class="my-0" />
|
||||
</template>
|
||||
<!-- Workspace mode: custom workspace item -->
|
||||
<template v-if="teamWorkspacesEnabled" #option="{ option }">
|
||||
<WorkspaceSidebarItem v-if="option.key === 'workspace'" />
|
||||
<span v-else>{{ option.translatedLabel }}</span>
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
|
||||
<Divider layout="horizontal" class="flex md:hidden" />
|
||||
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
|
||||
<Tabs
|
||||
:value="tabValue"
|
||||
:lazy="true"
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'h-full flex-1 overflow-x-auto'
|
||||
: 'settings-content h-full w-full'
|
||||
"
|
||||
>
|
||||
<TabPanels class="settings-tab-panels h-full w-full pr-0">
|
||||
<PanelTemplate value="Search Results">
|
||||
<SettingsPanel :setting-groups="searchResults" />
|
||||
@@ -48,7 +84,7 @@
|
||||
</PanelTemplate>
|
||||
|
||||
<Suspense v-for="panel in panels" :key="panel.node.key">
|
||||
<component :is="panel.component" />
|
||||
<component :is="panel.component" v-bind="panel.props" />
|
||||
<template #fallback>
|
||||
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
|
||||
</template>
|
||||
@@ -69,7 +105,10 @@ import { computed, watch } from 'vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
|
||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
||||
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
@@ -86,8 +125,15 @@ const { defaultPanel } = defineProps<{
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const {
|
||||
activeCategory,
|
||||
defaultCategory,
|
||||
@@ -162,6 +208,7 @@ watch(activeCategory, (_, oldValue) => {
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
|
||||
.settings-container {
|
||||
display: flex;
|
||||
height: 70vh;
|
||||
@@ -190,7 +237,7 @@ watch(activeCategory, (_, oldValue) => {
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the first group separator */
|
||||
/* Hide the first group separator in legacy mode */
|
||||
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,21 @@ import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
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
|
||||
component: Component
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useSettingUI(
|
||||
@@ -27,15 +29,21 @@ export function useSettingUI(
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const settingStore = useSettingStore()
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
Object.values(settingStore.settingsById).filter(
|
||||
@@ -64,6 +72,33 @@ export function useSettingUI(
|
||||
() => settingRoot.value.children ?? []
|
||||
)
|
||||
|
||||
// Core setting categories (built-in to ComfyUI) in display order
|
||||
// 'Other' includes floating settings that don't have a specific category
|
||||
const CORE_CATEGORIES_ORDER = [
|
||||
'Comfy',
|
||||
'LiteGraph',
|
||||
'Appearance',
|
||||
'3D',
|
||||
'Mask Editor',
|
||||
'Other'
|
||||
]
|
||||
const CORE_CATEGORIES = new Set(CORE_CATEGORIES_ORDER)
|
||||
|
||||
const coreSettingCategories = computed<SettingTreeNode[]>(() => {
|
||||
const categories = settingCategories.value.filter((node) =>
|
||||
CORE_CATEGORIES.has(node.label)
|
||||
)
|
||||
return categories.sort(
|
||||
(a, b) =>
|
||||
CORE_CATEGORIES_ORDER.indexOf(a.label) -
|
||||
CORE_CATEGORIES_ORDER.indexOf(b.label)
|
||||
)
|
||||
})
|
||||
|
||||
const customNodeSettingCategories = computed<SettingTreeNode[]>(() =>
|
||||
settingCategories.value.filter((node) => !CORE_CATEGORIES.has(node.label))
|
||||
)
|
||||
|
||||
// Define panel items
|
||||
const aboutPanel: SettingPanelItem = {
|
||||
node: {
|
||||
@@ -118,6 +153,22 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
// Workspace panel: only available on cloud with team workspaces enabled
|
||||
const workspacePanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/WorkspacePanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const shouldShowWorkspacePanel = computed(
|
||||
() => teamWorkspacesEnabled.value && isLoggedIn.value
|
||||
)
|
||||
|
||||
const keybindingPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'keybinding',
|
||||
@@ -156,13 +207,14 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isElectron() ? [serverConfigPanel] : []),
|
||||
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
|
||||
? [subscriptionPanel]
|
||||
: [])
|
||||
].filter((panel) => panel.component)
|
||||
].filter((panel) => panel !== null && panel.component)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -186,7 +238,47 @@ export function useSettingUI(
|
||||
)
|
||||
})
|
||||
|
||||
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||
// Sidebar structure when team workspaces is enabled
|
||||
const workspaceMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||
// Workspace settings
|
||||
translateCategory({
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
...(isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
: [])
|
||||
].map(translateCategory)
|
||||
}),
|
||||
// General settings - Profile + all core settings + special panels
|
||||
translateCategory({
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
children: [
|
||||
translateCategory(userPanel.node),
|
||||
...coreSettingCategories.value.map(translateCategory),
|
||||
translateCategory(keybindingPanel.node),
|
||||
translateCategory(extensionPanel.node),
|
||||
translateCategory(aboutPanel.node),
|
||||
...(isElectron() ? [translateCategory(serverConfigPanel.node)] : [])
|
||||
]
|
||||
}),
|
||||
// Custom node settings (only shown if custom nodes have registered settings)
|
||||
...(customNodeSettingCategories.value.length > 0
|
||||
? [
|
||||
translateCategory({
|
||||
key: 'other',
|
||||
label: 'Other',
|
||||
children: customNodeSettingCategories.value.map(translateCategory)
|
||||
})
|
||||
]
|
||||
: [])
|
||||
])
|
||||
|
||||
// Sidebar structure when team workspaces is disabled (legacy)
|
||||
const legacyMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||
// Account settings - show different panels based on distribution and auth state
|
||||
{
|
||||
key: 'account',
|
||||
@@ -223,6 +315,12 @@ export function useSettingUI(
|
||||
}
|
||||
])
|
||||
|
||||
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() =>
|
||||
teamWorkspacesEnabled.value
|
||||
? workspaceMenuTreeNodes.value
|
||||
: legacyMenuTreeNodes.value
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
activeCategory.value = defaultCategory.value
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
/**
|
||||
* Zendesk ticket form field IDs.
|
||||
*/
|
||||
const ZENDESK_FIELDS = {
|
||||
export const ZENDESK_FIELDS = {
|
||||
/** Distribution tag (cloud vs OSS) */
|
||||
DISTRIBUTION: 'tf_42243568391700',
|
||||
/** User email (anonymous requester) */
|
||||
@@ -14,6 +14,16 @@ const ZENDESK_FIELDS = {
|
||||
USER_ID: 'tf_42515251051412'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Gets the distribution identifier for Zendesk tracking.
|
||||
* Helps distinguish feedback from different build types.
|
||||
*/
|
||||
export function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
|
||||
if (isCloud) return 'ccloud'
|
||||
if (isNightly) return 'oss-nightly'
|
||||
return 'oss'
|
||||
}
|
||||
|
||||
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
|
||||
/**
|
||||
@@ -28,7 +38,7 @@ export function buildSupportUrl(params?: {
|
||||
userId?: string | null
|
||||
}): string {
|
||||
const searchParams = new URLSearchParams({
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: isCloud ? 'ccloud' : 'oss'
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
|
||||
})
|
||||
|
||||
if (params?.userEmail) {
|
||||
|
||||
@@ -526,7 +526,7 @@ const useCaseFuse = new Fuse(USE_CASE_CATEGORIES, FUSE_OPTIONS)
|
||||
/**
|
||||
* Normalize industry responses using Fuse.js fuzzy search
|
||||
*/
|
||||
export function normalizeIndustry(rawIndustry: string): string {
|
||||
export function normalizeIndustry(rawIndustry: unknown): string {
|
||||
if (!rawIndustry || typeof rawIndustry !== 'string') {
|
||||
return 'Other / Undefined'
|
||||
}
|
||||
@@ -554,7 +554,7 @@ export function normalizeIndustry(rawIndustry: string): string {
|
||||
/**
|
||||
* Normalize use case responses using Fuse.js fuzzy search
|
||||
*/
|
||||
export function normalizeUseCase(rawUseCase: string): string {
|
||||
export function normalizeUseCase(rawUseCase: unknown): string {
|
||||
if (!rawUseCase || typeof rawUseCase !== 'string') {
|
||||
return 'Other / Undefined'
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { t } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
type WorkspaceType = 'personal' | 'team'
|
||||
type WorkspaceRole = 'owner' | 'member'
|
||||
export type WorkspaceType = 'personal' | 'team'
|
||||
export type WorkspaceRole = 'owner' | 'member'
|
||||
|
||||
interface Workspace {
|
||||
id: string
|
||||
|
||||
125
src/platform/workspace/composables/useWorkspaceUI.ts
Normal file
125
src/platform/workspace/composables/useWorkspaceUI.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
|
||||
|
||||
/** Permission flags for workspace actions */
|
||||
interface WorkspacePermissions {
|
||||
canLeaveWorkspace: boolean
|
||||
canAccessWorkspaceMenu: boolean
|
||||
canManageSubscription: boolean
|
||||
}
|
||||
|
||||
/** UI configuration for workspace role */
|
||||
interface WorkspaceUIConfig {
|
||||
showEditWorkspaceMenuItem: boolean
|
||||
workspaceMenuAction: 'leave' | 'delete' | null
|
||||
workspaceMenuDisabledTooltip: string | null
|
||||
}
|
||||
|
||||
function getPermissions(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
): WorkspacePermissions {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
canLeaveWorkspace: false,
|
||||
canAccessWorkspaceMenu: false,
|
||||
canManageSubscription: true
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
return {
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true
|
||||
}
|
||||
}
|
||||
|
||||
// member role
|
||||
return {
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false
|
||||
}
|
||||
}
|
||||
|
||||
function getUIConfig(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
): WorkspaceUIConfig {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
showEditWorkspaceMenuItem: false,
|
||||
workspaceMenuAction: null,
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
return {
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete',
|
||||
workspaceMenuDisabledTooltip:
|
||||
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
|
||||
}
|
||||
}
|
||||
|
||||
// member role
|
||||
return {
|
||||
showEditWorkspaceMenuItem: false,
|
||||
workspaceMenuAction: 'leave',
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation of UI configuration composable.
|
||||
*/
|
||||
function useWorkspaceUIInternal() {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
// Tab management (shared UI state)
|
||||
const activeTab = ref<string>('plan')
|
||||
|
||||
function setActiveTab(tab: string | number) {
|
||||
activeTab.value = String(tab)
|
||||
}
|
||||
|
||||
const workspaceType = computed<WorkspaceType>(
|
||||
() => store.activeWorkspace?.type ?? 'personal'
|
||||
)
|
||||
|
||||
const workspaceRole = computed<WorkspaceRole>(
|
||||
() => store.activeWorkspace?.role ?? 'owner'
|
||||
)
|
||||
|
||||
const permissions = computed<WorkspacePermissions>(() =>
|
||||
getPermissions(workspaceType.value, workspaceRole.value)
|
||||
)
|
||||
|
||||
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||
getUIConfig(workspaceType.value, workspaceRole.value)
|
||||
)
|
||||
|
||||
return {
|
||||
// Tab management
|
||||
activeTab: computed(() => activeTab.value),
|
||||
setActiveTab,
|
||||
|
||||
// Permissions and config
|
||||
permissions,
|
||||
uiConfig,
|
||||
workspaceType,
|
||||
workspaceRole
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI configuration composable derived from workspace state.
|
||||
* Controls what UI elements are visible/enabled based on role and workspace type.
|
||||
* Uses createSharedComposable to ensure tab state is shared across components.
|
||||
*/
|
||||
export const useWorkspaceUI = createSharedComposable(useWorkspaceUIInternal)
|
||||
@@ -208,14 +208,33 @@ describe('useTeamWorkspaceStore', () => {
|
||||
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
|
||||
})
|
||||
|
||||
it('sets error state when workspaces fetch fails', async () => {
|
||||
it('sets error state when workspaces fetch fails after retries', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockWorkspaceApi.list.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
await expect(store.initialize()).rejects.toThrow('Network error')
|
||||
// Start initialization and catch rejections to prevent unhandled promise warning
|
||||
let initError: unknown = null
|
||||
const initPromise = store.initialize().catch((e: unknown) => {
|
||||
initError = e
|
||||
})
|
||||
|
||||
// Fast-forward through all retry delays (1s, 2s, 4s)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
|
||||
await initPromise
|
||||
|
||||
expect(initError).toBeInstanceOf(Error)
|
||||
expect((initError as Error).message).toBe('Network error')
|
||||
expect(store.initState).toBe('error')
|
||||
expect(store.error).toBeInstanceOf(Error)
|
||||
// Should have been called 4 times (initial + 3 retries)
|
||||
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(4)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not reinitialize if already initialized', async () => {
|
||||
|
||||
@@ -85,6 +85,8 @@ function setLastWorkspaceId(workspaceId: string): void {
|
||||
|
||||
const MAX_OWNED_WORKSPACES = 10
|
||||
const MAX_WORKSPACE_MEMBERS = 50
|
||||
const MAX_INIT_RETRIES = 3
|
||||
const BASE_RETRY_DELAY_MS = 1000
|
||||
|
||||
export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
const initState = ref<InitState>('uninitialized')
|
||||
@@ -174,6 +176,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
* Initialize the workspace store.
|
||||
* Fetches workspaces and resolves the active workspace from session/localStorage.
|
||||
* Delegates token management to workspaceAuthStore.
|
||||
* Retries on transient failures with exponential backoff.
|
||||
* Call once on app boot.
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
@@ -185,60 +188,115 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
|
||||
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||
|
||||
try {
|
||||
// 1. Try to restore workspace context from session (page refresh case)
|
||||
const hasValidSession = workspaceAuthStore.initializeFromSession()
|
||||
for (let attempt = 0; attempt <= MAX_INIT_RETRIES; attempt++) {
|
||||
try {
|
||||
// 1. Try to restore workspace context from session (page refresh case)
|
||||
const hasValidSession = workspaceAuthStore.initializeFromSession()
|
||||
|
||||
if (hasValidSession && workspaceAuthStore.currentWorkspace) {
|
||||
// Valid session exists - fetch workspace list and sync state
|
||||
if (hasValidSession && workspaceAuthStore.currentWorkspace) {
|
||||
// Valid session exists - fetch workspace list and verify access
|
||||
const response = await workspaceApi.list()
|
||||
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||
|
||||
if (workspaces.value.length === 0) {
|
||||
throw new Error('No workspaces available')
|
||||
}
|
||||
|
||||
// Verify session workspace exists in fetched list
|
||||
const sessionWorkspaceId = workspaceAuthStore.currentWorkspace.id
|
||||
const sessionWorkspaceExists = workspaces.value.some(
|
||||
(w) => w.id === sessionWorkspaceId
|
||||
)
|
||||
|
||||
if (sessionWorkspaceExists) {
|
||||
activeWorkspaceId.value = sessionWorkspaceId
|
||||
initState.value = 'ready'
|
||||
isFetchingWorkspaces.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Session workspace not found (deleted/access revoked) - fallback to default
|
||||
workspaceAuthStore.clearWorkspaceContext()
|
||||
|
||||
const personal = workspaces.value.find((w) => w.type === 'personal')
|
||||
const fallbackWorkspaceId = personal?.id ?? workspaces.value[0].id
|
||||
|
||||
try {
|
||||
await workspaceAuthStore.switchWorkspace(fallbackWorkspaceId)
|
||||
} catch {
|
||||
console.error(
|
||||
'[teamWorkspaceStore] Token exchange failed during fallback'
|
||||
)
|
||||
}
|
||||
|
||||
activeWorkspaceId.value = fallbackWorkspaceId
|
||||
setLastWorkspaceId(fallbackWorkspaceId)
|
||||
initState.value = 'ready'
|
||||
isFetchingWorkspaces.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 2. No valid session - fetch workspaces and pick default
|
||||
const response = await workspaceApi.list()
|
||||
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||
activeWorkspaceId.value = workspaceAuthStore.currentWorkspace.id
|
||||
|
||||
if (workspaces.value.length === 0) {
|
||||
throw new Error('No workspaces available')
|
||||
}
|
||||
|
||||
// 3. Determine target workspace (priority: localStorage > personal)
|
||||
let targetWorkspaceId: string | null = null
|
||||
|
||||
const lastId = getLastWorkspaceId()
|
||||
if (lastId && workspaces.value.some((w) => w.id === lastId)) {
|
||||
targetWorkspaceId = lastId
|
||||
}
|
||||
|
||||
if (!targetWorkspaceId) {
|
||||
const personal = workspaces.value.find((w) => w.type === 'personal')
|
||||
targetWorkspaceId = personal?.id ?? workspaces.value[0].id
|
||||
}
|
||||
|
||||
// 4. Exchange Firebase token for workspace token
|
||||
try {
|
||||
await workspaceAuthStore.switchWorkspace(targetWorkspaceId)
|
||||
} catch {
|
||||
// Log but don't fail initialization - API calls will fall back to Firebase token
|
||||
console.error(
|
||||
'[teamWorkspaceStore] Token exchange failed during init'
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Set active workspace
|
||||
activeWorkspaceId.value = targetWorkspaceId
|
||||
setLastWorkspaceId(targetWorkspaceId)
|
||||
|
||||
initState.value = 'ready'
|
||||
isFetchingWorkspaces.value = false
|
||||
return
|
||||
} catch (e) {
|
||||
const isNoWorkspacesError =
|
||||
e instanceof Error && e.message === 'No workspaces available'
|
||||
|
||||
// Don't retry on permanent errors (no workspaces available)
|
||||
if (isNoWorkspacesError || attempt >= MAX_INIT_RETRIES) {
|
||||
error.value = e instanceof Error ? e : new Error('Unknown error')
|
||||
initState.value = 'error'
|
||||
isFetchingWorkspaces.value = false
|
||||
throw e
|
||||
}
|
||||
|
||||
// Retry with exponential backoff for transient errors
|
||||
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt)
|
||||
const errorMessage = e instanceof Error ? e.message : String(e)
|
||||
console.warn(
|
||||
`[teamWorkspaceStore] Init failed (attempt ${attempt + 1}/${MAX_INIT_RETRIES + 1}), retrying in ${delay}ms: ${errorMessage}`
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
|
||||
// 2. No valid session - fetch workspaces and pick default
|
||||
const response = await workspaceApi.list()
|
||||
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||
|
||||
if (workspaces.value.length === 0) {
|
||||
throw new Error('No workspaces available')
|
||||
}
|
||||
|
||||
// 3. Determine target workspace (priority: localStorage > personal)
|
||||
let targetWorkspaceId: string | null = null
|
||||
|
||||
const lastId = getLastWorkspaceId()
|
||||
if (lastId && workspaces.value.some((w) => w.id === lastId)) {
|
||||
targetWorkspaceId = lastId
|
||||
}
|
||||
|
||||
if (!targetWorkspaceId) {
|
||||
const personal = workspaces.value.find((w) => w.type === 'personal')
|
||||
targetWorkspaceId = personal?.id ?? workspaces.value[0].id
|
||||
}
|
||||
|
||||
// 4. Exchange Firebase token for workspace token
|
||||
try {
|
||||
await workspaceAuthStore.switchWorkspace(targetWorkspaceId)
|
||||
} catch {
|
||||
// Log but don't fail initialization - API calls will fall back to Firebase token
|
||||
console.error('[teamWorkspaceStore] Token exchange failed during init')
|
||||
}
|
||||
|
||||
// 5. Set active workspace
|
||||
activeWorkspaceId.value = targetWorkspaceId
|
||||
setLastWorkspaceId(targetWorkspaceId)
|
||||
|
||||
initState.value = 'ready'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e : new Error('Unknown error')
|
||||
initState.value = 'error'
|
||||
throw e
|
||||
} finally {
|
||||
isFetchingWorkspaces.value = false
|
||||
}
|
||||
|
||||
isFetchingWorkspaces.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -99,6 +99,7 @@ import { computed, onErrorCaptured, ref, toValue, watch } from 'vue'
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -183,9 +184,67 @@ const statusBadge = computed((): NodeBadgeProps | undefined =>
|
||||
: undefined
|
||||
)
|
||||
|
||||
const nodeBadges = computed<NodeBadgeProps[]>(() =>
|
||||
[...(nodeData?.badges ?? [])].map(toValue)
|
||||
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
|
||||
const {
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing,
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getNodeRevisionRef
|
||||
} = useNodePricing()
|
||||
// Cache pricing metadata (won't change during node lifetime)
|
||||
const isDynamicPricing = computed(() =>
|
||||
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
|
||||
)
|
||||
const relevantPricingWidgets = computed(() =>
|
||||
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
|
||||
)
|
||||
const inputGroupPrefixes = computed(() =>
|
||||
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
|
||||
)
|
||||
const relevantInputNames = computed(() =>
|
||||
nodeData?.apiNode ? getInputNames(nodeData.type) : []
|
||||
)
|
||||
const nodeBadges = computed<NodeBadgeProps[]>(() => {
|
||||
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
|
||||
// This is needed even for static pricing because JSONata 2.x evaluation is async
|
||||
if (nodeData?.apiNode && nodeData?.id != null) {
|
||||
// Access per-node revision ref to establish dependency (each node has its own ref)
|
||||
void getNodeRevisionRef(nodeData.id).value
|
||||
|
||||
// For dynamic pricing, also track widget values and input connections
|
||||
if (isDynamicPricing.value) {
|
||||
// Access only the widget values that affect pricing
|
||||
const relevantNames = relevantPricingWidgets.value
|
||||
if (relevantNames.length > 0) {
|
||||
nodeData?.widgets?.forEach((w) => {
|
||||
if (relevantNames.includes(w.name)) w.value
|
||||
})
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
const inputNames = relevantInputNames.value
|
||||
if (inputNames.length > 0) {
|
||||
nodeData?.inputs?.forEach((inp) => {
|
||||
if (inp.name && inputNames.includes(inp.name)) {
|
||||
void inp.link // Access link to create reactive dependency
|
||||
}
|
||||
})
|
||||
}
|
||||
// Access input connections for input_groups (e.g., autogrow inputs)
|
||||
const groupPrefixes = inputGroupPrefixes.value
|
||||
if (groupPrefixes.length > 0) {
|
||||
nodeData?.inputs?.forEach((inp) => {
|
||||
if (
|
||||
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
|
||||
) {
|
||||
void inp.link // Access link to create reactive dependency
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...(nodeData?.badges ?? [])].map(toValue)
|
||||
})
|
||||
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
|
||||
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
|
||||
|
||||
|
||||
@@ -159,14 +159,15 @@ function handleMouseMove(e: PointerEvent) {
|
||||
function handleMouseUp() {
|
||||
const newValue = dragValue.value
|
||||
if (newValue === undefined) return
|
||||
modelValue.value = newValue
|
||||
dragValue.value = undefined
|
||||
|
||||
if (dragDelta.value === 0) {
|
||||
if (newValue === modelValue.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.setSelectionRange(0, -1)
|
||||
}
|
||||
|
||||
modelValue.value = newValue
|
||||
dragValue.value = undefined
|
||||
dragDelta.value = 0
|
||||
}
|
||||
|
||||
@@ -202,9 +203,13 @@ const sliderWidth = computed(() => {
|
||||
:class="cn(WidgetInputBaseClass, 'grow text-xs flex h-7 relative')"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-background/15 absolute left-0 bottom-0 h-full rounded-lg pointer-events-none"
|
||||
:style="{ width: `${sliderWidth}%` }"
|
||||
/>
|
||||
class="absolute size-full rounded-lg pointer-events-none overflow-clip"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-background/15 size-full"
|
||||
:style="{ width: `${sliderWidth}%` }"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!buttonsDisabled"
|
||||
data-testid="decrement"
|
||||
|
||||
@@ -197,6 +197,50 @@ const zComfyOutputTypesSpec = z.array(
|
||||
z.union([zComfyNodeDataType, zComfyComboOutput])
|
||||
)
|
||||
|
||||
/**
|
||||
* Widget dependency with type information.
|
||||
* Provides strong type enforcement for JSONata evaluation context.
|
||||
*/
|
||||
const zWidgetDependency = z.object({
|
||||
name: z.string(),
|
||||
type: z.string()
|
||||
})
|
||||
|
||||
export type WidgetDependency = z.infer<typeof zWidgetDependency>
|
||||
|
||||
/**
|
||||
* Schema for price badge depends_on field.
|
||||
* Specifies which widgets and inputs the pricing expression depends on.
|
||||
* Widgets must be specified as objects with name and type.
|
||||
*/
|
||||
const zPriceBadgeDepends = z.object({
|
||||
widgets: z.array(zWidgetDependency).optional().default([]),
|
||||
inputs: z.array(z.string()).optional().default([]),
|
||||
/**
|
||||
* Autogrow input group names to track.
|
||||
* For each group, the count of connected inputs will be available in the
|
||||
* JSONata context as `g.<groupName>`.
|
||||
* Example: `input_groups: ["reference_videos"]` makes `g.reference_videos`
|
||||
* available with the count of connected inputs like `reference_videos.character1`, etc.
|
||||
*/
|
||||
input_groups: z.array(z.string()).optional().default([])
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for price badge definition.
|
||||
* Used to calculate and display pricing information for API nodes.
|
||||
* The `expr` field contains a JSONata expression that returns a PricingResult.
|
||||
*/
|
||||
const zPriceBadge = z.object({
|
||||
engine: z.literal('jsonata').optional().default('jsonata'),
|
||||
depends_on: zPriceBadgeDepends
|
||||
.optional()
|
||||
.default({ widgets: [], inputs: [], input_groups: [] }),
|
||||
expr: z.string()
|
||||
})
|
||||
|
||||
export type PriceBadge = z.infer<typeof zPriceBadge>
|
||||
|
||||
export const zComfyNodeDef = z.object({
|
||||
input: zComfyInputsSpec.optional(),
|
||||
output: zComfyOutputTypesSpec.optional(),
|
||||
@@ -224,7 +268,18 @@ export const zComfyNodeDef = z.object({
|
||||
* Used to ensure consistent widget ordering regardless of JSON serialization.
|
||||
* Keys are 'required', 'optional', etc., values are arrays of input names.
|
||||
*/
|
||||
input_order: z.record(z.array(z.string())).optional()
|
||||
input_order: z.record(z.array(z.string())).optional(),
|
||||
/**
|
||||
* Alternative names for search. Useful for synonyms, abbreviations,
|
||||
* or old names after renaming a node.
|
||||
*/
|
||||
search_aliases: z.array(z.string()).optional(),
|
||||
/**
|
||||
* Price badge definition for API nodes.
|
||||
* Contains a JSONata expression to calculate pricing based on widget values
|
||||
* and input connectivity.
|
||||
*/
|
||||
price_badge: zPriceBadge.optional()
|
||||
})
|
||||
|
||||
export const zAutogrowOptions = z.object({
|
||||
|
||||
@@ -162,7 +162,7 @@ export class ComfyApp {
|
||||
|
||||
// TODO: Migrate internal usage to the
|
||||
/** @deprecated Use {@link rootGraph} instead */
|
||||
get graph(): unknown {
|
||||
get graph(): LGraph | undefined {
|
||||
return this.rootGraphInternal!
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export const useDialogService = () => {
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
) {
|
||||
const props = panel ? { props: { defaultPanel: panel } } : undefined
|
||||
|
||||
@@ -519,6 +520,75 @@ export const useDialogService = () => {
|
||||
show()
|
||||
}
|
||||
|
||||
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
|
||||
const workspaceDialogPt = {
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
} as const
|
||||
|
||||
async function showDeleteWorkspaceDialog(options?: {
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
}) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'delete-workspace',
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
async function showCreateWorkspaceDialog(
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'create-workspace',
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt,
|
||||
pt: {
|
||||
...workspaceDialogPt.pt,
|
||||
root: { class: 'rounded-2xl max-w-[400px] w-full' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function showLeaveWorkspaceDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'leave-workspace',
|
||||
component,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
async function showEditWorkspaceDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'edit-workspace',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt,
|
||||
pt: {
|
||||
...workspaceDialogPt.pt,
|
||||
root: { class: 'rounded-2xl max-w-[400px] w-full' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
showLoadWorkflowWarning,
|
||||
showMissingModelsWarning,
|
||||
@@ -536,6 +606,10 @@ export const useDialogService = () => {
|
||||
confirm,
|
||||
showLayoutDialog,
|
||||
showImportFailedNodeDialog,
|
||||
showNodeConflictDialog
|
||||
showNodeConflictDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showCreateWorkspaceDialog,
|
||||
showLeaveWorkspaceDialog,
|
||||
showEditWorkspaceDialog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export class NodeSearchService {
|
||||
constructor(data: ComfyNodeDefImpl[]) {
|
||||
this.nodeFuseSearch = new FuseSearch(data, {
|
||||
fuseOptions: {
|
||||
keys: ['name', 'display_name'],
|
||||
keys: ['name', 'display_name', 'search_aliases'],
|
||||
includeScore: true,
|
||||
threshold: 0.3,
|
||||
shouldSort: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAsyncState, whenever } from '@vueuse/core'
|
||||
import { difference } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref, shallowReactive } from 'vue'
|
||||
import {
|
||||
@@ -455,32 +456,71 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
/**
|
||||
* Update asset metadata with optimistic cache update
|
||||
* @param assetId The asset ID to update
|
||||
* @param asset The asset to update
|
||||
* @param userMetadata The user_metadata to save
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetMetadata(
|
||||
assetId: string,
|
||||
asset: AssetItem,
|
||||
userMetadata: Record<string, unknown>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
updateAssetInCache(assetId, { user_metadata: userMetadata }, cacheKey)
|
||||
await assetService.updateAsset(assetId, { user_metadata: userMetadata })
|
||||
const originalMetadata = asset.user_metadata
|
||||
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
|
||||
|
||||
try {
|
||||
const updatedAsset = await assetService.updateAsset(asset.id, {
|
||||
user_metadata: userMetadata
|
||||
})
|
||||
updateAssetInCache(asset.id, updatedAsset, cacheKey)
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset metadata:', error)
|
||||
updateAssetInCache(
|
||||
asset.id,
|
||||
{ user_metadata: originalMetadata },
|
||||
cacheKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset tags with optimistic cache update
|
||||
* @param assetId The asset ID to update
|
||||
* @param tags The tags array to save
|
||||
* Update asset tags using add/remove endpoints
|
||||
* @param asset The asset to update (used to read current tags)
|
||||
* @param newTags The desired tags array
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetTags(
|
||||
assetId: string,
|
||||
tags: string[],
|
||||
asset: AssetItem,
|
||||
newTags: string[],
|
||||
cacheKey?: string
|
||||
) {
|
||||
updateAssetInCache(assetId, { tags }, cacheKey)
|
||||
await assetService.updateAsset(assetId, { tags })
|
||||
const originalTags = asset.tags
|
||||
const tagsToAdd = difference(newTags, originalTags)
|
||||
const tagsToRemove = difference(originalTags, newTags)
|
||||
|
||||
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
|
||||
|
||||
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
|
||||
|
||||
try {
|
||||
const removeResult =
|
||||
tagsToRemove.length > 0
|
||||
? await assetService.removeAssetTags(asset.id, tagsToRemove)
|
||||
: undefined
|
||||
|
||||
const addResult =
|
||||
tagsToAdd.length > 0
|
||||
? await assetService.addAssetTags(asset.id, tagsToAdd)
|
||||
: undefined
|
||||
|
||||
const finalTags = (addResult ?? removeResult)?.total_tags
|
||||
if (finalTags) {
|
||||
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset tags:', error)
|
||||
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -512,6 +512,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
sendPasswordReset,
|
||||
updatePassword: _updatePassword,
|
||||
deleteAccount: _deleteAccount,
|
||||
getAuthHeader
|
||||
getAuthHeader,
|
||||
getFirebaseAuthHeader
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,7 +14,8 @@ import type {
|
||||
import type {
|
||||
ComfyInputsSpec as ComfyInputSpecV1,
|
||||
ComfyNodeDef as ComfyNodeDefV1,
|
||||
ComfyOutputTypesSpec as ComfyOutputSpecV1
|
||||
ComfyOutputTypesSpec as ComfyOutputSpecV1,
|
||||
PriceBadge
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { NodeSearchService } from '@/services/nodeSearchService'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
@@ -66,6 +67,12 @@ export class ComfyNodeDefImpl
|
||||
* Order of inputs for each category (required, optional, hidden)
|
||||
*/
|
||||
readonly input_order?: Record<string, string[]>
|
||||
/**
|
||||
* Price badge definition for API nodes.
|
||||
* Contains a JSONata expression to calculate pricing based on widget values
|
||||
* and input connectivity.
|
||||
*/
|
||||
readonly price_badge?: PriceBadge
|
||||
|
||||
// V2 fields
|
||||
readonly inputs: Record<string, InputSpecV2>
|
||||
@@ -134,6 +141,7 @@ export class ComfyNodeDefImpl
|
||||
this.output_name = obj.output_name
|
||||
this.output_tooltips = obj.output_tooltips
|
||||
this.input_order = obj.input_order
|
||||
this.price_badge = obj.price_badge
|
||||
|
||||
// Initialize V2 fields
|
||||
const defV2 = transformNodeDefV1ToV2(obj)
|
||||
|
||||
@@ -64,6 +64,17 @@ export type {
|
||||
ToastMessageOptions
|
||||
}
|
||||
|
||||
interface CapturedMessages {
|
||||
clientFeatureFlags: { type: string; data: Record<string, unknown> } | null
|
||||
serverFeatureFlags: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface AppReadiness {
|
||||
featureFlagsReceived: boolean
|
||||
apiInitialized: boolean
|
||||
appInitialized: boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** For use by extensions and in the browser console. Where possible, import `app` from '@/scripts/app' instead. */
|
||||
@@ -71,5 +82,11 @@ declare global {
|
||||
|
||||
/** For use by extensions and in the browser console. Where possible, import `app` and access via `app.graph` instead. */
|
||||
graph?: unknown
|
||||
|
||||
/** For use in tests to capture WebSocket messages */
|
||||
__capturedMessages?: CapturedMessages
|
||||
|
||||
/** For use in tests to track app initialization state */
|
||||
__appReadiness?: AppReadiness
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||
import { i18n, loadLocale } from '@/i18n'
|
||||
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -252,6 +253,27 @@ const onReconnected = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize workspace store when feature flag and auth become available
|
||||
// Uses watch because remoteConfig loads asynchronously after component mount
|
||||
if (isCloud) {
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
watch(
|
||||
() => [flags.teamWorkspacesEnabled, firebaseAuthStore.isAuthenticated],
|
||||
async ([enabled, isAuthenticated]) => {
|
||||
if (!enabled || !isAuthenticated) return
|
||||
|
||||
const { useTeamWorkspaceStore } =
|
||||
await import('@/platform/workspace/stores/teamWorkspaceStore')
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
if (workspaceStore.initState === 'uninitialized') {
|
||||
await workspaceStore.initialize()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
api.addEventListener('status', onStatus)
|
||||
api.addEventListener('execution_success', onExecutionSuccess)
|
||||
@@ -352,7 +374,10 @@ const onGraphReady = () => {
|
||||
})
|
||||
|
||||
// Broadcast our heartbeat
|
||||
tabCountChannel?.postMessage({ type: 'heartbeat', tabId: currentTabId })
|
||||
tabCountChannel?.postMessage({
|
||||
type: 'heartbeat',
|
||||
tabId: currentTabId
|
||||
})
|
||||
|
||||
// Track tab count (include current tab)
|
||||
const tabCount = activeTabs.size + 1
|
||||
|
||||
Reference in New Issue
Block a user