mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +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)
|
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
||||||
|
|
||||||
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
||||||
// eslint-disable-next-line no-console
|
console.warn(
|
||||||
console.log(
|
|
||||||
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
||||||
{
|
{
|
||||||
nodePos: [node.pos[0], node.pos[1]],
|
nodePos: [node.pos[0], node.pos[1]],
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test.describe('Feature Flags', () => {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data)
|
const parsed = JSON.parse(data)
|
||||||
if (parsed.type === 'feature_flags') {
|
if (parsed.type === 'feature_flags') {
|
||||||
window.__capturedMessages.clientFeatureFlags = parsed
|
window.__capturedMessages!.clientFeatureFlags = parsed
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not JSON, ignore
|
// Not JSON, ignore
|
||||||
@@ -41,7 +41,7 @@ test.describe('Feature Flags', () => {
|
|||||||
window['app']?.api?.serverFeatureFlags &&
|
window['app']?.api?.serverFeatureFlags &&
|
||||||
Object.keys(window['app'].api.serverFeatureFlags).length > 0
|
Object.keys(window['app'].api.serverFeatureFlags).length > 0
|
||||||
) {
|
) {
|
||||||
window.__capturedMessages.serverFeatureFlags =
|
window.__capturedMessages!.serverFeatureFlags =
|
||||||
window['app'].api.serverFeatureFlags
|
window['app'].api.serverFeatureFlags
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
}
|
}
|
||||||
@@ -57,8 +57,8 @@ test.describe('Feature Flags', () => {
|
|||||||
// Wait for both client and server feature flags
|
// Wait for both client and server feature flags
|
||||||
await newPage.waitForFunction(
|
await newPage.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
window.__capturedMessages.clientFeatureFlags !== null &&
|
window.__capturedMessages!.clientFeatureFlags !== null &&
|
||||||
window.__capturedMessages.serverFeatureFlags !== null,
|
window.__capturedMessages!.serverFeatureFlags !== null,
|
||||||
{ timeout: 10000 }
|
{ timeout: 10000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,27 +66,27 @@ test.describe('Feature Flags', () => {
|
|||||||
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
||||||
|
|
||||||
// Verify client sent feature flags
|
// Verify client sent feature flags
|
||||||
expect(messages.clientFeatureFlags).toBeTruthy()
|
expect(messages!.clientFeatureFlags).toBeTruthy()
|
||||||
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||||
expect(messages.clientFeatureFlags).toHaveProperty('data')
|
expect(messages!.clientFeatureFlags).toHaveProperty('data')
|
||||||
expect(messages.clientFeatureFlags.data).toHaveProperty(
|
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
|
||||||
'supports_preview_metadata'
|
'supports_preview_metadata'
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
typeof messages.clientFeatureFlags.data.supports_preview_metadata
|
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
|
||||||
).toBe('boolean')
|
).toBe('boolean')
|
||||||
|
|
||||||
// Verify server sent feature flags back
|
// Verify server sent feature flags back
|
||||||
expect(messages.serverFeatureFlags).toBeTruthy()
|
expect(messages!.serverFeatureFlags).toBeTruthy()
|
||||||
expect(messages.serverFeatureFlags).toHaveProperty(
|
expect(messages!.serverFeatureFlags).toHaveProperty(
|
||||||
'supports_preview_metadata'
|
'supports_preview_metadata'
|
||||||
)
|
)
|
||||||
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
|
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
|
||||||
'boolean'
|
'boolean'
|
||||||
)
|
)
|
||||||
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
|
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||||
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
|
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
|
||||||
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
|
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
|
||||||
|
|
||||||
await newPage.close()
|
await newPage.close()
|
||||||
})
|
})
|
||||||
@@ -96,7 +96,7 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Get the actual server feature flags from the backend
|
// Get the actual server feature flags from the backend
|
||||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
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
|
// Verify we received real feature flags from the backend
|
||||||
@@ -115,7 +115,7 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Test serverSupportsFeature with real backend flags
|
// Test serverSupportsFeature with real backend flags
|
||||||
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.serverSupportsFeature(
|
return window['app']!.api.serverSupportsFeature(
|
||||||
'supports_preview_metadata'
|
'supports_preview_metadata'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -124,15 +124,17 @@ test.describe('Feature Flags', () => {
|
|||||||
|
|
||||||
// Test non-existent feature - should always return false
|
// Test non-existent feature - should always return false
|
||||||
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
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)
|
expect(supportsNonExistent).toBe(false)
|
||||||
|
|
||||||
// Test that the method only returns true for boolean true values
|
// Test that the method only returns true for boolean true values
|
||||||
const testResults = await comfyPage.page.evaluate(() => {
|
const testResults = await comfyPage.page.evaluate(() => {
|
||||||
// Temporarily modify serverFeatureFlags to test behavior
|
// Temporarily modify serverFeatureFlags to test behavior
|
||||||
const original = window['app'].api.serverFeatureFlags
|
const original = window['app']!.api.serverFeatureFlags
|
||||||
window['app'].api.serverFeatureFlags = {
|
window['app']!.api.serverFeatureFlags = {
|
||||||
bool_true: true,
|
bool_true: true,
|
||||||
bool_false: false,
|
bool_false: false,
|
||||||
string_value: 'yes',
|
string_value: 'yes',
|
||||||
@@ -141,15 +143,15 @@ test.describe('Feature Flags', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
|
bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
|
||||||
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
|
bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
|
||||||
string_value: window['app'].api.serverSupportsFeature('string_value'),
|
string_value: window['app']!.api.serverSupportsFeature('string_value'),
|
||||||
number_value: window['app'].api.serverSupportsFeature('number_value'),
|
number_value: window['app']!.api.serverSupportsFeature('number_value'),
|
||||||
null_value: window['app'].api.serverSupportsFeature('null_value')
|
null_value: window['app']!.api.serverSupportsFeature('null_value')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore original
|
// Restore original
|
||||||
window['app'].api.serverFeatureFlags = original
|
window['app']!.api.serverFeatureFlags = original
|
||||||
return results
|
return results
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,20 +168,20 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Test getServerFeature method
|
// Test getServerFeature method
|
||||||
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
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')
|
expect(typeof previewMetadataValue).toBe('boolean')
|
||||||
|
|
||||||
// Test getting max_upload_size
|
// Test getting max_upload_size
|
||||||
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
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(typeof maxUploadSize).toBe('number')
|
||||||
expect(maxUploadSize).toBeGreaterThan(0)
|
expect(maxUploadSize).toBeGreaterThan(0)
|
||||||
|
|
||||||
// Test getServerFeature with default value for non-existent feature
|
// Test getServerFeature with default value for non-existent feature
|
||||||
const defaultValue = await comfyPage.page.evaluate(() => {
|
const defaultValue = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.getServerFeature(
|
return window['app']!.api.getServerFeature(
|
||||||
'non_existent_feature_xyz',
|
'non_existent_feature_xyz',
|
||||||
'default'
|
'default'
|
||||||
)
|
)
|
||||||
@@ -192,7 +194,7 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Test getServerFeatures returns all flags
|
// Test getServerFeatures returns all flags
|
||||||
const allFeatures = await comfyPage.page.evaluate(() => {
|
const allFeatures = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.getServerFeatures()
|
return window['app']!.api.getServerFeatures()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(allFeatures).toBeTruthy()
|
expect(allFeatures).toBeTruthy()
|
||||||
@@ -205,14 +207,14 @@ test.describe('Feature Flags', () => {
|
|||||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||||
// Test that getClientFeatureFlags returns a copy
|
// Test that getClientFeatureFlags returns a copy
|
||||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||||
const flags1 = window['app'].api.getClientFeatureFlags()
|
const flags1 = window['app']!.api.getClientFeatureFlags()
|
||||||
const flags2 = window['app'].api.getClientFeatureFlags()
|
const flags2 = window['app']!.api.getClientFeatureFlags()
|
||||||
|
|
||||||
// Modify the first object
|
// Modify the first object
|
||||||
flags1.test_modification = true
|
flags1.test_modification = true
|
||||||
|
|
||||||
// Get flags again to check if original was modified
|
// Get flags again to check if original was modified
|
||||||
const flags3 = window['app'].api.getClientFeatureFlags()
|
const flags3 = window['app']!.api.getClientFeatureFlags()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
areEqual: flags1 === flags2,
|
areEqual: flags1 === flags2,
|
||||||
@@ -238,14 +240,14 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||||
// Get a copy of server features
|
// Get a copy of server features
|
||||||
const features1 = window['app'].api.getServerFeatures()
|
const features1 = window['app']!.api.getServerFeatures()
|
||||||
|
|
||||||
// Try to modify it
|
// Try to modify it
|
||||||
features1.supports_preview_metadata = false
|
features1.supports_preview_metadata = false
|
||||||
features1.new_feature = 'added'
|
features1.new_feature = 'added'
|
||||||
|
|
||||||
// Get another copy
|
// Get another copy
|
||||||
const features2 = window['app'].api.getServerFeatures()
|
const features2 = window['app']!.api.getServerFeatures()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
modifiedValue: features1.supports_preview_metadata,
|
modifiedValue: features1.supports_preview_metadata,
|
||||||
@@ -274,7 +276,8 @@ test.describe('Feature Flags', () => {
|
|||||||
// Set up monitoring before navigation
|
// Set up monitoring before navigation
|
||||||
await newPage.addInitScript(() => {
|
await newPage.addInitScript(() => {
|
||||||
// Track when various app components are ready
|
// Track when various app components are ready
|
||||||
;(window as any).__appReadiness = {
|
|
||||||
|
window.__appReadiness = {
|
||||||
featureFlagsReceived: false,
|
featureFlagsReceived: false,
|
||||||
apiInitialized: false,
|
apiInitialized: false,
|
||||||
appInitialized: false
|
appInitialized: false
|
||||||
@@ -286,7 +289,10 @@ test.describe('Feature Flags', () => {
|
|||||||
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||||
undefined
|
undefined
|
||||||
) {
|
) {
|
||||||
;(window as any).__appReadiness.featureFlagsReceived = true
|
window.__appReadiness = {
|
||||||
|
...window.__appReadiness,
|
||||||
|
featureFlagsReceived: true
|
||||||
|
}
|
||||||
clearInterval(checkFeatureFlags)
|
clearInterval(checkFeatureFlags)
|
||||||
}
|
}
|
||||||
}, 10)
|
}, 10)
|
||||||
@@ -294,7 +300,10 @@ test.describe('Feature Flags', () => {
|
|||||||
// Monitor API initialization
|
// Monitor API initialization
|
||||||
const checkApi = setInterval(() => {
|
const checkApi = setInterval(() => {
|
||||||
if (window['app']?.api) {
|
if (window['app']?.api) {
|
||||||
;(window as any).__appReadiness.apiInitialized = true
|
window.__appReadiness = {
|
||||||
|
...window.__appReadiness,
|
||||||
|
apiInitialized: true
|
||||||
|
}
|
||||||
clearInterval(checkApi)
|
clearInterval(checkApi)
|
||||||
}
|
}
|
||||||
}, 10)
|
}, 10)
|
||||||
@@ -302,7 +311,10 @@ test.describe('Feature Flags', () => {
|
|||||||
// Monitor app initialization
|
// Monitor app initialization
|
||||||
const checkApp = setInterval(() => {
|
const checkApp = setInterval(() => {
|
||||||
if (window['app']?.graph) {
|
if (window['app']?.graph) {
|
||||||
;(window as any).__appReadiness.appInitialized = true
|
window.__appReadiness = {
|
||||||
|
...window.__appReadiness,
|
||||||
|
appInitialized: true
|
||||||
|
}
|
||||||
clearInterval(checkApp)
|
clearInterval(checkApp)
|
||||||
}
|
}
|
||||||
}, 10)
|
}, 10)
|
||||||
@@ -331,8 +343,8 @@ test.describe('Feature Flags', () => {
|
|||||||
// Get readiness state
|
// Get readiness state
|
||||||
const readiness = await newPage.evaluate(() => {
|
const readiness = await newPage.evaluate(() => {
|
||||||
return {
|
return {
|
||||||
...(window as any).__appReadiness,
|
...window.__appReadiness,
|
||||||
currentFlags: window['app'].api.serverFeatureFlags
|
currentFlags: window['app']!.api.serverFeatureFlags
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import {
|
|||||||
comfyExpect as expect,
|
comfyExpect as expect,
|
||||||
comfyPageFixture as test
|
comfyPageFixture as test
|
||||||
} from '../fixtures/ComfyPage'
|
} from '../fixtures/ComfyPage'
|
||||||
|
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||||
import { fitToViewInstant } from '../helpers/fitToView'
|
import { fitToViewInstant } from '../helpers/fitToView'
|
||||||
|
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||||
|
|
||||||
// TODO: there might be a better solution for this
|
// TODO: there might be a better solution for this
|
||||||
// Helper function to pan canvas and select node
|
// 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()
|
const nodePos = await nodeRef.getPosition()
|
||||||
|
|
||||||
await comfyPage.page.evaluate((pos) => {
|
await comfyPage.page.evaluate((pos) => {
|
||||||
const app = window['app']
|
const app = window['app']!
|
||||||
const canvas = app.canvas
|
const canvas = app.canvas
|
||||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
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
|
// Find and select a custom/group node
|
||||||
const nodeRefs = await comfyPage.page.evaluate(() => {
|
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) {
|
if (nodeRefs.length > 0) {
|
||||||
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
|
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||||
|
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||||
|
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||||
@@ -15,7 +16,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
|
|||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
})
|
})
|
||||||
|
|
||||||
const openMoreOptions = async (comfyPage: any) => {
|
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||||
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
||||||
if (ksamplerNodes.length === 0) {
|
if (ksamplerNodes.length === 0) {
|
||||||
throw new Error('No KSampler nodes found')
|
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.
|
// This avoids relying on an exact path hit-test position.
|
||||||
await comfyPage.page.evaluate(
|
await comfyPage.page.evaluate(
|
||||||
([targetNodeId, targetSlot, clientPoint]) => {
|
([targetNodeId, targetSlot, clientPoint]) => {
|
||||||
const app = (window as any)['app']
|
const app = window['app']
|
||||||
const graph = app?.canvas?.graph ?? app?.graph
|
const graph = app?.canvas?.graph ?? app?.graph
|
||||||
if (!graph) throw new Error('Graph not available')
|
if (!graph) throw new Error('Graph not available')
|
||||||
const node = graph.getNodeById(targetNodeId)
|
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.
|
// This avoids relying on an exact path hit-test position.
|
||||||
await comfyPage.page.evaluate(
|
await comfyPage.page.evaluate(
|
||||||
([targetNodeId, targetSlot, clientPoint]) => {
|
([targetNodeId, targetSlot, clientPoint]) => {
|
||||||
const app = (window as any)['app']
|
const app = window['app']
|
||||||
const graph = app?.canvas?.graph ?? app?.graph
|
const graph = app?.canvas?.graph ?? app?.graph
|
||||||
if (!graph) throw new Error('Graph not available')
|
if (!graph) throw new Error('Graph not available')
|
||||||
const node = graph.getNodeById(targetNodeId)
|
const node = graph.getNodeById(targetNodeId)
|
||||||
|
|||||||
@@ -169,6 +169,7 @@
|
|||||||
"firebase": "catalog:",
|
"firebase": "catalog:",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
|
"jsonata": "catalog:",
|
||||||
"jsondiffpatch": "^0.6.0",
|
"jsondiffpatch": "^0.6.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"marked": "^15.0.11",
|
"marked": "^15.0.11",
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ describe('formatUtil', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle null and undefined gracefully', () => {
|
it('should handle null and undefined gracefully', () => {
|
||||||
expect(getMediaTypeFromFilename(null as any)).toBe('image')
|
expect(getMediaTypeFromFilename(null)).toBe('image')
|
||||||
expect(getMediaTypeFromFilename(undefined as any)).toBe('image')
|
expect(getMediaTypeFromFilename(undefined)).toBe('image')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle special characters in filenames', () => {
|
it('should handle special characters in filenames', () => {
|
||||||
|
|||||||
@@ -537,7 +537,9 @@ export function truncateFilename(
|
|||||||
* @param filename The filename to analyze
|
* @param filename The filename to analyze
|
||||||
* @returns The media type: 'image', 'video', 'audio', or '3D'
|
* @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'
|
if (!filename) return 'image'
|
||||||
const ext = filename.split('.').pop()?.toLowerCase()
|
const ext = filename.split('.').pop()?.toLowerCase()
|
||||||
if (!ext) return 'image'
|
if (!ext) return 'image'
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -186,6 +186,9 @@ catalogs:
|
|||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^27.4.0
|
specifier: ^27.4.0
|
||||||
version: 27.4.0
|
version: 27.4.0
|
||||||
|
jsonata:
|
||||||
|
specifier: ^2.1.0
|
||||||
|
version: 2.1.0
|
||||||
knip:
|
knip:
|
||||||
specifier: ^5.75.1
|
specifier: ^5.75.1
|
||||||
version: 5.75.1
|
version: 5.75.1
|
||||||
@@ -449,6 +452,9 @@ importers:
|
|||||||
glob:
|
glob:
|
||||||
specifier: ^11.0.3
|
specifier: ^11.0.3
|
||||||
version: 11.0.3
|
version: 11.0.3
|
||||||
|
jsonata:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 2.1.0
|
||||||
jsondiffpatch:
|
jsondiffpatch:
|
||||||
specifier: ^0.6.0
|
specifier: ^0.6.0
|
||||||
version: 0.6.0
|
version: 0.6.0
|
||||||
@@ -6045,6 +6051,10 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonata@2.1.0:
|
||||||
|
resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
jsonc-eslint-parser@2.4.0:
|
jsonc-eslint-parser@2.4.0:
|
||||||
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
|
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
@@ -14403,6 +14413,8 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jsonata@2.1.0: {}
|
||||||
|
|
||||||
jsonc-eslint-parser@2.4.0:
|
jsonc-eslint-parser@2.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ catalog:
|
|||||||
happy-dom: ^20.0.11
|
happy-dom: ^20.0.11
|
||||||
husky: ^9.1.7
|
husky: ^9.1.7
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
|
jsonata: ^2.1.0
|
||||||
jsdom: ^27.4.0
|
jsdom: ^27.4.0
|
||||||
knip: ^5.75.1
|
knip: ^5.75.1
|
||||||
lint-staged: ^16.2.7
|
lint-staged: ^16.2.7
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface IdleDeadline {
|
|||||||
interface IDisposable {
|
interface IDisposable {
|
||||||
dispose(): void
|
dispose(): void
|
||||||
}
|
}
|
||||||
|
type GlobalWindow = typeof globalThis
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal implementation function that handles the actual scheduling logic.
|
* Internal implementation function that handles the actual scheduling logic.
|
||||||
@@ -21,7 +22,7 @@ interface IDisposable {
|
|||||||
* or fall back to setTimeout-based implementation.
|
* or fall back to setTimeout-based implementation.
|
||||||
*/
|
*/
|
||||||
let _runWhenIdle: (
|
let _runWhenIdle: (
|
||||||
targetWindow: any,
|
targetWindow: GlobalWindow,
|
||||||
callback: (idle: IdleDeadline) => void,
|
callback: (idle: IdleDeadline) => void,
|
||||||
timeout?: number
|
timeout?: number
|
||||||
) => IDisposable
|
) => IDisposable
|
||||||
@@ -37,7 +38,7 @@ export let runWhenGlobalIdle: (
|
|||||||
|
|
||||||
// Self-invoking function to set up the idle callback implementation
|
// Self-invoking function to set up the idle callback implementation
|
||||||
;(function () {
|
;(function () {
|
||||||
const safeGlobal: any = globalThis
|
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { MenuItem } from 'primevue/menuitem'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { computed, nextTick } from 'vue'
|
import { computed, nextTick } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
@@ -42,7 +43,8 @@ function createWrapper() {
|
|||||||
queueProgressOverlay: {
|
queueProgressOverlay: {
|
||||||
viewJobHistory: 'View job history',
|
viewJobHistory: 'View job history',
|
||||||
expandCollapsedQueue: 'Expand collapsed queue',
|
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,
|
SubgraphBreadcrumb: true,
|
||||||
QueueProgressOverlay: true,
|
QueueProgressOverlay: true,
|
||||||
CurrentUserButton: true,
|
CurrentUserButton: true,
|
||||||
LoginButton: true
|
LoginButton: true,
|
||||||
|
ContextMenu: {
|
||||||
|
name: 'ContextMenu',
|
||||||
|
props: ['model'],
|
||||||
|
template: '<div />'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
tooltip: () => {}
|
tooltip: () => {}
|
||||||
@@ -134,4 +141,24 @@ describe('TopMenuSection', () => {
|
|||||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||||
expect(queueButton.text()).toContain('3 active')
|
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"
|
class="px-3"
|
||||||
data-testid="queue-overlay-toggle"
|
data-testid="queue-overlay-toggle"
|
||||||
@click="toggleQueueOverlay"
|
@click="toggleQueueOverlay"
|
||||||
|
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-normal tabular-nums">
|
<span class="text-sm font-normal tabular-nums">
|
||||||
{{ activeJobsLabel }}
|
{{ activeJobsLabel }}
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
|
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||||
<CurrentUserButton
|
<CurrentUserButton
|
||||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
@@ -84,6 +86,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import ContextMenu from 'primevue/contextmenu'
|
||||||
|
import type { MenuItem } from 'primevue/menuitem'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -101,6 +105,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
|||||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
@@ -119,6 +124,7 @@ const { t, n } = useI18n()
|
|||||||
const { toastErrorHandler } = useErrorHandling()
|
const { toastErrorHandler } = useErrorHandling()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
|
const executionStore = useExecutionStore()
|
||||||
const queueUIStore = useQueueUIStore()
|
const queueUIStore = useQueueUIStore()
|
||||||
const { activeJobsCount } = storeToRefs(queueStore)
|
const { activeJobsCount } = storeToRefs(queueStore)
|
||||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||||
@@ -144,6 +150,18 @@ const queueHistoryTooltipConfig = computed(() =>
|
|||||||
const customNodesManagerTooltipConfig = computed(() =>
|
const customNodesManagerTooltipConfig = computed(() =>
|
||||||
buildTooltipConfig(t('menu.customNodesManager'))
|
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
|
// Use either release red dot or conflict red dot
|
||||||
const shouldShowRedDot = computed((): boolean => {
|
const shouldShowRedDot = computed((): boolean => {
|
||||||
@@ -170,6 +188,19 @@ const toggleQueueOverlay = () => {
|
|||||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
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 () => {
|
const openCustomNodeManager = async () => {
|
||||||
try {
|
try {
|
||||||
await managerState.openManager({
|
await managerState.openManager({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import type { VueWrapper } from '@vue/test-utils'
|
import type { VueWrapper } from '@vue/test-utils'
|
||||||
import { mount } 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
@@ -151,8 +152,8 @@ describe('BaseTerminal', () => {
|
|||||||
// Trigger the selection change callback that was registered during mount
|
// Trigger the selection change callback that was registered during mount
|
||||||
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
|
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
|
||||||
// Access the mock calls - TypeScript can't infer the mock structure dynamically
|
// Access the mock calls - TypeScript can't infer the mock structure dynamically
|
||||||
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
|
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
|
||||||
.calls[0][0]
|
const selectionCallback = mockCalls[0][0] as () => void
|
||||||
selectionCallback()
|
selectionCallback()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createApp } from 'vue'
|
|||||||
import type { SettingOption } from '@/platform/settings/types'
|
import type { SettingOption } from '@/platform/settings/types'
|
||||||
|
|
||||||
import FormRadioGroup from './FormRadioGroup.vue'
|
import FormRadioGroup from './FormRadioGroup.vue'
|
||||||
|
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||||
|
|
||||||
describe('FormRadioGroup', () => {
|
describe('FormRadioGroup', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -14,7 +15,8 @@ describe('FormRadioGroup', () => {
|
|||||||
app.use(PrimeVue)
|
app.use(PrimeVue)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountComponent = (props: any, options = {}) => {
|
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
|
||||||
|
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
|
||||||
return mount(FormRadioGroup, {
|
return mount(FormRadioGroup, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue],
|
plugins: [PrimeVue],
|
||||||
@@ -92,9 +94,9 @@ describe('FormRadioGroup', () => {
|
|||||||
|
|
||||||
it('handles custom object with optionLabel and optionValue', () => {
|
it('handles custom object with optionLabel and optionValue', () => {
|
||||||
const options = [
|
const options = [
|
||||||
{ name: 'First Option', id: 1 },
|
{ name: 'First Option', id: '1' },
|
||||||
{ name: 'Second Option', id: 2 },
|
{ name: 'Second Option', id: '2' },
|
||||||
{ name: 'Third Option', id: 3 }
|
{ name: 'Third Option', id: '3' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mountComponent({
|
const wrapper = mountComponent({
|
||||||
@@ -108,9 +110,9 @@ describe('FormRadioGroup', () => {
|
|||||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||||
expect(radioButtons).toHaveLength(3)
|
expect(radioButtons).toHaveLength(3)
|
||||||
|
|
||||||
expect(radioButtons[0].props('value')).toBe(1)
|
expect(radioButtons[0].props('value')).toBe('1')
|
||||||
expect(radioButtons[1].props('value')).toBe(2)
|
expect(radioButtons[1].props('value')).toBe('2')
|
||||||
expect(radioButtons[2].props('value')).toBe(3)
|
expect(radioButtons[2].props('value')).toBe('3')
|
||||||
|
|
||||||
const labels = wrapper.findAll('label')
|
const labels = wrapper.findAll('label')
|
||||||
expect(labels[0].text()).toBe('First Option')
|
expect(labels[0].text()).toBe('First Option')
|
||||||
@@ -167,10 +169,7 @@ describe('FormRadioGroup', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles object with missing properties gracefully', () => {
|
it('handles object with missing properties gracefully', () => {
|
||||||
const options = [
|
const options = [{ label: 'Option 1', val: 'opt1' }]
|
||||||
{ label: 'Option 1', val: 'opt1' },
|
|
||||||
{ text: 'Option 2', value: 'opt2' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const wrapper = mountComponent({
|
const wrapper = mountComponent({
|
||||||
modelValue: 'opt1',
|
modelValue: 'opt1',
|
||||||
@@ -179,11 +178,10 @@ describe('FormRadioGroup', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||||
expect(radioButtons).toHaveLength(2)
|
expect(radioButtons).toHaveLength(1)
|
||||||
|
|
||||||
const labels = wrapper.findAll('label')
|
const labels = wrapper.findAll('label')
|
||||||
expect(labels[0].text()).toBe('Unknown')
|
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<{
|
const props = defineProps<{
|
||||||
modelValue: any
|
modelValue: any
|
||||||
options: (SettingOption | string)[]
|
options?: (string | SettingOption | Record<string, string>)[]
|
||||||
optionLabel?: string
|
optionLabel?: string
|
||||||
optionValue?: string
|
optionValue?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
|||||||
import { createApp, nextTick } from 'vue'
|
import { createApp, nextTick } from 'vue'
|
||||||
|
|
||||||
import UrlInput from './UrlInput.vue'
|
import UrlInput from './UrlInput.vue'
|
||||||
|
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||||
|
|
||||||
describe('UrlInput', () => {
|
describe('UrlInput', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -14,7 +15,13 @@ describe('UrlInput', () => {
|
|||||||
app.use(PrimeVue)
|
app.use(PrimeVue)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountComponent = (props: any, options = {}) => {
|
const mountComponent = (
|
||||||
|
props: ComponentProps<typeof UrlInput> & {
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
},
|
||||||
|
options = {}
|
||||||
|
) => {
|
||||||
return mount(UrlInput, {
|
return mount(UrlInput, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue],
|
plugins: [PrimeVue],
|
||||||
@@ -169,25 +176,25 @@ describe('UrlInput', () => {
|
|||||||
await input.setValue(' https://leading-space.com')
|
await input.setValue(' https://leading-space.com')
|
||||||
await input.trigger('input')
|
await input.trigger('input')
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
|
expect(input.element.value).toBe('https://leading-space.com')
|
||||||
|
|
||||||
// Test trailing whitespace
|
// Test trailing whitespace
|
||||||
await input.setValue('https://trailing-space.com ')
|
await input.setValue('https://trailing-space.com ')
|
||||||
await input.trigger('input')
|
await input.trigger('input')
|
||||||
await nextTick()
|
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
|
// Test both leading and trailing whitespace
|
||||||
await input.setValue(' https://both-spaces.com ')
|
await input.setValue(' https://both-spaces.com ')
|
||||||
await input.trigger('input')
|
await input.trigger('input')
|
||||||
await nextTick()
|
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
|
// Test whitespace in the middle of the URL
|
||||||
await input.setValue('https:// middle-space.com')
|
await input.setValue('https:// middle-space.com')
|
||||||
await input.trigger('input')
|
await input.trigger('input')
|
||||||
await nextTick()
|
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 () => {
|
it('trims whitespace when value set externally', async () => {
|
||||||
@@ -196,15 +203,17 @@ describe('UrlInput', () => {
|
|||||||
placeholder: 'Enter URL'
|
placeholder: 'Enter URL'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
// Check initial value is trimmed
|
// 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
|
// Update props with whitespace
|
||||||
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
|
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Check updated value is trimmed
|
// 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 { mount } from '@vue/test-utils'
|
||||||
import Avatar from 'primevue/avatar'
|
import Avatar from 'primevue/avatar'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
@@ -27,7 +29,7 @@ describe('UserAvatar', () => {
|
|||||||
app.use(PrimeVue)
|
app.use(PrimeVue)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountComponent = (props: any = {}) => {
|
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
|
||||||
return mount(UserAvatar, {
|
return mount(UserAvatar, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, i18n],
|
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"
|
v-for="item in dialogStore.dialogStack"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
v-model:visible="item.visible"
|
v-model:visible="item.visible"
|
||||||
class="global-dialog"
|
:class="[
|
||||||
|
'global-dialog',
|
||||||
|
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||||
|
? 'settings-dialog-workspace'
|
||||||
|
: ''
|
||||||
|
]"
|
||||||
v-bind="item.dialogComponentProps"
|
v-bind="item.dialogComponentProps"
|
||||||
:pt="item.dialogComponentProps.pt"
|
:pt="item.dialogComponentProps.pt"
|
||||||
:aria-labelledby="item.key"
|
:aria-labelledby="item.key"
|
||||||
@@ -38,7 +43,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Dialog from 'primevue/dialog'
|
import Dialog from 'primevue/dialog'
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
const teamWorkspacesEnabled = computed(
|
||||||
|
() => isCloud && flags.teamWorkspacesEnabled
|
||||||
|
)
|
||||||
|
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
</script>
|
</script>
|
||||||
@@ -55,4 +68,27 @@ const dialogStore = useDialogStore()
|
|||||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||||
@apply pt-0;
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
describe('SettingItem', () => {
|
describe('SettingItem', () => {
|
||||||
const mountComponent = (props: any, options = {}): any => {
|
const mountComponent = (props: Record<string, unknown>, options = {}) => {
|
||||||
return mount(SettingItem, {
|
return mount(SettingItem, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, i18n, createPinia()],
|
plugins: [PrimeVue, i18n, createPinia()],
|
||||||
@@ -32,6 +32,7 @@ describe('SettingItem', () => {
|
|||||||
'i-material-symbols:experiment-outline': true
|
'i-material-symbols:experiment-outline': true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
|
||||||
props,
|
props,
|
||||||
...options
|
...options
|
||||||
})
|
})
|
||||||
@@ -48,8 +49,9 @@ describe('SettingItem', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get the options property of the FormItem
|
// Check the FormItem component's item prop for the options
|
||||||
const options = wrapper.vm.formItem.options
|
const formItem = wrapper.findComponent({ name: 'FormItem' })
|
||||||
|
const options = formItem.props('item').options
|
||||||
expect(options).toEqual([
|
expect(options).toEqual([
|
||||||
{ text: 'Correctly Translated', value: 'Correctly Translated' }
|
{ text: 'Correctly Translated', value: 'Correctly Translated' }
|
||||||
])
|
])
|
||||||
@@ -67,7 +69,8 @@ describe('SettingItem', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Should not throw an error and tooltip should be preserved as-is
|
// 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'
|
'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 { nextTick } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { AuditLog } from '@/services/customerEventsService'
|
||||||
import { EventType } from '@/services/customerEventsService'
|
import { EventType } from '@/services/customerEventsService'
|
||||||
|
|
||||||
import UsageLogsTable from './UsageLogsTable.vue'
|
import UsageLogsTable from './UsageLogsTable.vue'
|
||||||
@@ -19,7 +20,7 @@ import UsageLogsTable from './UsageLogsTable.vue'
|
|||||||
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
|
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
events: any[]
|
events: Partial<AuditLog>[]
|
||||||
pagination: {
|
pagination: {
|
||||||
page: number
|
page: number
|
||||||
limit: 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 { Form } from '@primevue/forms'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
@@ -63,7 +65,7 @@ describe('ApiKeyForm', () => {
|
|||||||
mockLoading.mockReset()
|
mockLoading.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountComponent = (props: any = {}) => {
|
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
|
||||||
return mount(ApiKeyForm, {
|
return mount(ApiKeyForm, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, createPinia(), i18n],
|
plugins: [PrimeVue, createPinia(), i18n],
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ describe('SignInForm', () => {
|
|||||||
|
|
||||||
// Mock getElementById to track focus
|
// Mock getElementById to track focus
|
||||||
const mockFocus = vi.fn()
|
const mockFocus = vi.fn()
|
||||||
const mockElement = { focus: mockFocus }
|
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||||
|
mockElement as HTMLElement
|
||||||
|
)
|
||||||
|
|
||||||
// Click forgot password link while email is empty
|
// Click forgot password link while email is empty
|
||||||
await forgotPasswordSpan.trigger('click')
|
await forgotPasswordSpan.trigger('click')
|
||||||
@@ -138,7 +140,10 @@ describe('SignInForm', () => {
|
|||||||
|
|
||||||
it('calls handleForgotPassword with email when link is clicked', async () => {
|
it('calls handleForgotPassword with email when link is clicked', async () => {
|
||||||
const wrapper = mountComponent()
|
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
|
// Spy on handleForgotPassword
|
||||||
const handleForgotPasswordSpy = vi.spyOn(
|
const handleForgotPasswordSpy = vi.spyOn(
|
||||||
@@ -161,7 +166,10 @@ describe('SignInForm', () => {
|
|||||||
describe('Form Submission', () => {
|
describe('Form Submission', () => {
|
||||||
it('emits submit event when onSubmit is called with valid data', async () => {
|
it('emits submit event when onSubmit is called with valid data', async () => {
|
||||||
const wrapper = mountComponent()
|
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
|
// Call onSubmit directly with valid data
|
||||||
component.onSubmit({
|
component.onSubmit({
|
||||||
@@ -181,7 +189,10 @@ describe('SignInForm', () => {
|
|||||||
|
|
||||||
it('does not emit submit event when form is invalid', async () => {
|
it('does not emit submit event when form is invalid', async () => {
|
||||||
const wrapper = mountComponent()
|
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
|
// Call onSubmit with invalid form
|
||||||
component.onSubmit({ valid: false, values: {} })
|
component.onSubmit({ valid: false, values: {} })
|
||||||
@@ -254,12 +265,17 @@ describe('SignInForm', () => {
|
|||||||
describe('Focus Behavior', () => {
|
describe('Focus Behavior', () => {
|
||||||
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
|
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
|
||||||
const wrapper = mountComponent()
|
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
|
// Mock getElementById to track focus
|
||||||
const mockFocus = vi.fn()
|
const mockFocus = vi.fn()
|
||||||
const mockElement = { focus: mockFocus }
|
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||||
|
mockElement as HTMLElement
|
||||||
|
)
|
||||||
|
|
||||||
// Call handleForgotPassword with no email
|
// Call handleForgotPassword with no email
|
||||||
await component.handleForgotPassword('', false)
|
await component.handleForgotPassword('', false)
|
||||||
@@ -273,12 +289,17 @@ describe('SignInForm', () => {
|
|||||||
|
|
||||||
it('does not focus email input when valid email is provided', async () => {
|
it('does not focus email input when valid email is provided', async () => {
|
||||||
const wrapper = mountComponent()
|
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
|
// Mock getElementById
|
||||||
const mockFocus = vi.fn()
|
const mockFocus = vi.fn()
|
||||||
const mockElement = { focus: mockFocus }
|
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||||
|
mockElement as HTMLElement
|
||||||
|
)
|
||||||
|
|
||||||
// Call handleForgotPassword with valid email
|
// Call handleForgotPassword with valid email
|
||||||
await component.handleForgotPassword('test@example.com', true)
|
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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<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" />
|
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||||
</div>
|
</div>
|
||||||
@@ -27,38 +36,65 @@
|
|||||||
:show-arrow="false"
|
:show-arrow="false"
|
||||||
:pt="{
|
:pt="{
|
||||||
root: {
|
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>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import Popover from 'primevue/popover'
|
import Popover from 'primevue/popover'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||||
|
|
||||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||||
|
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
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 { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||||
|
|
||||||
|
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||||
|
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||||
|
)
|
||||||
|
|
||||||
const { showArrow = true, compact = false } = defineProps<{
|
const { showArrow = true, compact = false } = defineProps<{
|
||||||
showArrow?: boolean
|
showArrow?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
|
||||||
|
|
||||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||||
|
|
||||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
|
||||||
const photoURL = computed<string | undefined>(
|
const photoURL = computed<string | undefined>(
|
||||||
() => userPhotoUrl.value ?? 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 = () => {
|
const closePopover = () => {
|
||||||
popover.value?.hide()
|
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',
|
md: 'h-8 rounded-lg p-2 text-xs',
|
||||||
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
|
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
|
||||||
icon: 'size-8',
|
icon: 'size-8',
|
||||||
'icon-sm': 'size-5 p-0'
|
'icon-sm': 'size-5 p-0',
|
||||||
|
unset: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ onClickOutside(rootEl, () => {
|
|||||||
<i
|
<i
|
||||||
v-if="!disabled && !isEditing"
|
v-if="!disabled && !isEditing"
|
||||||
aria-hidden="true"
|
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>
|
</TagsInputRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export const useNodeBadge = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const nodePricing = useNodePricing()
|
const nodePricing = useNodePricing()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => nodePricing.pricingRevision.value,
|
||||||
|
() => {
|
||||||
|
if (!showApiPricingBadge.value) return
|
||||||
|
app.canvas?.setDirty(true, true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
extensionStore.registerExtension({
|
extensionStore.registerExtension({
|
||||||
name: 'Comfy.NodeBadge',
|
name: 'Comfy.NodeBadge',
|
||||||
nodeCreated(node: LGraphNode) {
|
nodeCreated(node: LGraphNode) {
|
||||||
@@ -111,17 +119,16 @@ export const useNodeBadge = () => {
|
|||||||
node.badges.push(() => badge.value)
|
node.badges.push(() => badge.value)
|
||||||
|
|
||||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.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 pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||||
const hasDynamicPricing =
|
const hasDynamicPricing =
|
||||||
typeof pricingConfig?.displayPrice === 'function'
|
!!pricingConfig &&
|
||||||
|
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
|
||||||
let creditsBadge
|
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
|
||||||
const createBadge = () => {
|
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
|
||||||
const price = nodePricing.getNodeDisplayPrice(node)
|
|
||||||
return priceBadge.getCreditsBadge(price)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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) {
|
if (hasDynamicPricing) {
|
||||||
// For dynamic pricing nodes, use computed that watches widget changes
|
// For dynamic pricing nodes, use computed that watches widget changes
|
||||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||||
@@ -133,13 +140,63 @@ export const useNodeBadge = () => {
|
|||||||
triggerCanvasRedraw: true
|
triggerCanvasRedraw: true
|
||||||
})
|
})
|
||||||
|
|
||||||
creditsBadge = computedWithWidgetWatch(createBadge)
|
// Ensure watchers are installed; ignore the returned value.
|
||||||
} else {
|
// (This call is what registers the widget listeners in most implementations.)
|
||||||
// For static pricing nodes, use regular computed
|
computedWithWidgetWatch(() => 0)
|
||||||
creditsBadge = computed(createBadge)
|
|
||||||
|
// 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() {
|
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 { t } from '@/i18n'
|
||||||
|
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
|
||||||
import { useExtensionService } from '@/services/extensionService'
|
import { useExtensionService } from '@/services/extensionService'
|
||||||
import type { ActionBarButton } from '@/types/comfy'
|
import type { ActionBarButton } from '@/types/comfy'
|
||||||
|
|
||||||
// Zendesk feedback URL - update this with the actual URL
|
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||||
const ZENDESK_FEEDBACK_URL =
|
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
|
||||||
'https://support.comfy.org/hc/en-us/requests/new?ticket_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[] = [
|
const buttons: ActionBarButton[] = [
|
||||||
{
|
{
|
||||||
@@ -12,7 +19,7 @@ const buttons: ActionBarButton[] = [
|
|||||||
label: t('actionbar.feedback'),
|
label: t('actionbar.feedback'),
|
||||||
tooltip: t('actionbar.feedbackTooltip'),
|
tooltip: t('actionbar.feedbackTooltip'),
|
||||||
onClick: () => {
|
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('./cloudRemoteConfig')
|
||||||
await import('./cloudBadges')
|
await import('./cloudBadges')
|
||||||
await import('./cloudSessionCookie')
|
await import('./cloudSessionCookie')
|
||||||
await import('./cloudFeedbackTopbarButton')
|
|
||||||
|
|
||||||
if (window.__CONFIG__?.subscription_required) {
|
if (window.__CONFIG__?.subscription_required) {
|
||||||
await import('./cloudSubscription')
|
await import('./cloudSubscription')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feedback button for cloud and nightly builds
|
||||||
|
if (isCloud || isNightly) {
|
||||||
|
await import('./cloudFeedbackTopbarButton')
|
||||||
|
}
|
||||||
|
|
||||||
// Nightly-only extensions
|
// Nightly-only extensions
|
||||||
if (isNightly && !isCloud) {
|
if (isNightly && !isCloud) {
|
||||||
await import('./nightlyBadges')
|
await import('./nightlyBadges')
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
"reportIssueTooltip": "Submit the error report to Comfy Org",
|
"reportIssueTooltip": "Submit the error report to Comfy Org",
|
||||||
"reportSent": "Report Submitted",
|
"reportSent": "Report Submitted",
|
||||||
"copyToClipboard": "Copy to Clipboard",
|
"copyToClipboard": "Copy to Clipboard",
|
||||||
|
"copyAll": "Copy All",
|
||||||
"openNewIssue": "Open New Issue",
|
"openNewIssue": "Open New Issue",
|
||||||
"showReport": "Show Report",
|
"showReport": "Show Report",
|
||||||
"imageFailedToLoad": "Image failed to load",
|
"imageFailedToLoad": "Image failed to load",
|
||||||
@@ -1297,7 +1298,10 @@
|
|||||||
"VueNodes": "Nodes 2.0",
|
"VueNodes": "Nodes 2.0",
|
||||||
"Nodes 2_0": "Nodes 2.0",
|
"Nodes 2_0": "Nodes 2.0",
|
||||||
"Execution": "Execution",
|
"Execution": "Execution",
|
||||||
"PLY": "PLY"
|
"PLY": "PLY",
|
||||||
|
"Workspace": "Workspace",
|
||||||
|
"General": "General",
|
||||||
|
"Other": "Other"
|
||||||
},
|
},
|
||||||
"serverConfigItems": {
|
"serverConfigItems": {
|
||||||
"listen": {
|
"listen": {
|
||||||
@@ -2021,6 +2025,8 @@
|
|||||||
"renewsDate": "Renews {date}",
|
"renewsDate": "Renews {date}",
|
||||||
"expiresDate": "Expires {date}",
|
"expiresDate": "Expires {date}",
|
||||||
"manageSubscription": "Manage subscription",
|
"manageSubscription": "Manage subscription",
|
||||||
|
"managePayment": "Manage Payment",
|
||||||
|
"cancelSubscription": "Cancel Subscription",
|
||||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||||
"partnerNodesDescription": "For running commercial/proprietary models",
|
"partnerNodesDescription": "For running commercial/proprietary models",
|
||||||
"totalCredits": "Total credits",
|
"totalCredits": "Total credits",
|
||||||
@@ -2075,6 +2081,9 @@
|
|||||||
"subscribeToRunFull": "Subscribe to Run",
|
"subscribeToRunFull": "Subscribe to Run",
|
||||||
"subscribeNow": "Subscribe Now",
|
"subscribeNow": "Subscribe Now",
|
||||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
"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",
|
"description": "Choose the best plan for you",
|
||||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||||
"contactUs": "Contact us",
|
"contactUs": "Contact us",
|
||||||
@@ -2110,12 +2119,64 @@
|
|||||||
"userSettings": {
|
"userSettings": {
|
||||||
"title": "My Account Settings",
|
"title": "My Account Settings",
|
||||||
"accountSettings": "Account settings",
|
"accountSettings": "Account settings",
|
||||||
|
"workspaceSettings": "Workspace settings",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"provider": "Sign-in Provider",
|
"provider": "Sign-in Provider",
|
||||||
"notSet": "Not set",
|
"notSet": "Not set",
|
||||||
"updatePassword": "Update Password"
|
"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": {
|
"selectionToolbox": {
|
||||||
"executeButton": {
|
"executeButton": {
|
||||||
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
|
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
|
||||||
@@ -2431,6 +2492,7 @@
|
|||||||
"selectModelPrompt": "Select a model to see its information",
|
"selectModelPrompt": "Select a model to see its information",
|
||||||
"basicInfo": "Basic Info",
|
"basicInfo": "Basic Info",
|
||||||
"displayName": "Display Name",
|
"displayName": "Display Name",
|
||||||
|
"editDisplayName": "Edit display name",
|
||||||
"fileName": "File Name",
|
"fileName": "File Name",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"viewOnSource": "View on {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."
|
"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>
|
<template>
|
||||||
<div class="flex flex-col gap-1 px-4 py-2 text-sm text-muted-foreground">
|
<div class="flex flex-col gap-2 px-4 py-2 text-sm text-base-foreground">
|
||||||
<span>{{ label }}</span>
|
<div class="flex items-center justify-between relative">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<slot name="label-action" />
|
||||||
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
|
||||||
import ModelInfoPanel from './ModelInfoPanel.vue'
|
import ModelInfoPanel from './ModelInfoPanel.vue'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||||
|
useCopyToClipboard: () => ({
|
||||||
|
copyToClipboard: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
|
|||||||
@@ -10,17 +10,29 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<ModelInfoField :label="t('assetBrowser.modelInfo.displayName')">
|
<ModelInfoField :label="t('assetBrowser.modelInfo.displayName')">
|
||||||
<EditableText
|
<div class="group flex justify-between">
|
||||||
:model-value="displayName"
|
<EditableText
|
||||||
:is-editing="isEditingDisplayName"
|
:model-value="displayName"
|
||||||
:class="cn('break-all', !isImmutable && 'text-base-foreground')"
|
:is-editing="isEditingDisplayName"
|
||||||
@dblclick="isEditingDisplayName = !isImmutable"
|
:class="cn('break-all text-muted-foreground flex-auto')"
|
||||||
@edit="handleDisplayNameEdit"
|
@dblclick="isEditingDisplayName = !isImmutable"
|
||||||
@cancel="isEditingDisplayName = false"
|
@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>
|
||||||
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
|
<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>
|
||||||
<ModelInfoField
|
<ModelInfoField
|
||||||
v-if="sourceUrl"
|
v-if="sourceUrl"
|
||||||
@@ -51,7 +63,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
|
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
|
||||||
<Select v-model="selectedModelType" :disabled="isImmutable">
|
<Select v-if="!isImmutable" v-model="selectedModelType">
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full">
|
||||||
<SelectValue
|
<SelectValue
|
||||||
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
|
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
|
||||||
@@ -67,6 +79,12 @@
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
<ModelInfoField :label="t('assetBrowser.modelInfo.compatibleBaseModels')">
|
<ModelInfoField :label="t('assetBrowser.modelInfo.compatibleBaseModels')">
|
||||||
<TagsInput
|
<TagsInput
|
||||||
@@ -124,14 +142,31 @@
|
|||||||
v-if="triggerPhrases.length > 0"
|
v-if="triggerPhrases.length > 0"
|
||||||
:label="t('assetBrowser.modelInfo.triggerPhrases')"
|
:label="t('assetBrowser.modelInfo.triggerPhrases')"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap gap-1">
|
<template #label-action>
|
||||||
<span
|
<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"
|
v-for="phrase in triggerPhrases"
|
||||||
:key="phrase"
|
: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 }}
|
{{ phrase }}
|
||||||
</span>
|
<i class="icon-[lucide--copy] size-4 min-w-4 min-h-4 opacity-60" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ModelInfoField>
|
</ModelInfoField>
|
||||||
<ModelInfoField
|
<ModelInfoField
|
||||||
@@ -170,7 +205,9 @@ import { computed, ref, useTemplateRef, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import EditableText from '@/components/common/EditableText.vue'
|
import EditableText from '@/components/common/EditableText.vue'
|
||||||
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
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 Select from '@/components/ui/select/Select.vue'
|
||||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||||
@@ -201,6 +238,7 @@ import { cn } from '@/utils/tailwindUtil'
|
|||||||
import ModelInfoField from './ModelInfoField.vue'
|
import ModelInfoField from './ModelInfoField.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
|
|
||||||
const descriptionTextarea = useTemplateRef<HTMLTextAreaElement>(
|
const descriptionTextarea = useTemplateRef<HTMLTextAreaElement>(
|
||||||
'descriptionTextarea'
|
'descriptionTextarea'
|
||||||
@@ -219,6 +257,7 @@ const assetsStore = useAssetsStore()
|
|||||||
const { modelTypes } = useModelTypes()
|
const { modelTypes } = useModelTypes()
|
||||||
|
|
||||||
const pendingUpdates = ref<AssetUserMetadata>({})
|
const pendingUpdates = ref<AssetUserMetadata>({})
|
||||||
|
const pendingModelType = ref<string | undefined>(undefined)
|
||||||
const isEditingDisplayName = ref(false)
|
const isEditingDisplayName = ref(false)
|
||||||
|
|
||||||
const isImmutable = computed(() => asset.is_immutable ?? true)
|
const isImmutable = computed(() => asset.is_immutable ?? true)
|
||||||
@@ -239,10 +278,17 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => asset.tags,
|
||||||
|
() => {
|
||||||
|
pendingModelType.value = undefined
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const debouncedFlushMetadata = useDebounceFn(() => {
|
const debouncedFlushMetadata = useDebounceFn(() => {
|
||||||
if (isImmutable.value) return
|
if (isImmutable.value) return
|
||||||
assetsStore.updateAssetMetadata(
|
assetsStore.updateAssetMetadata(
|
||||||
asset.id,
|
asset,
|
||||||
{ ...(asset.user_metadata ?? {}), ...pendingUpdates.value },
|
{ ...(asset.user_metadata ?? {}), ...pendingUpdates.value },
|
||||||
cacheKey
|
cacheKey
|
||||||
)
|
)
|
||||||
@@ -267,7 +313,7 @@ const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
|
|||||||
const newTags = asset.tags
|
const newTags = asset.tags
|
||||||
.filter((tag) => tag !== currentModelType)
|
.filter((tag) => tag !== currentModelType)
|
||||||
.concat(newModelType)
|
.concat(newModelType)
|
||||||
assetsStore.updateAssetTags(asset.id, newTags, cacheKey)
|
assetsStore.updateAssetTags(asset, newTags, cacheKey)
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|
||||||
const baseModels = computed({
|
const baseModels = computed({
|
||||||
@@ -288,9 +334,11 @@ const userDescription = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const selectedModelType = computed({
|
const selectedModelType = computed({
|
||||||
get: () => getAssetModelType(asset) ?? undefined,
|
get: () => pendingModelType.value ?? getAssetModelType(asset) ?? undefined,
|
||||||
set: (value: string | undefined) => {
|
set: (value: string | undefined) => {
|
||||||
if (value) debouncedSaveModelType(value)
|
if (!value) return
|
||||||
|
pendingModelType.value = value
|
||||||
|
debouncedSaveModelType(value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ const zAssetUserMetadata = z.object({
|
|||||||
|
|
||||||
export type AssetUserMetadata = z.infer<typeof zAssetUserMetadata>
|
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)
|
// Legacy interface for backward compatibility (now aligned with Zod schema)
|
||||||
export interface ModelFolderInfo {
|
export interface ModelFolderInfo {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { st } from '@/i18n'
|
|||||||
import {
|
import {
|
||||||
assetItemSchema,
|
assetItemSchema,
|
||||||
assetResponseSchema,
|
assetResponseSchema,
|
||||||
asyncUploadResponseSchema
|
asyncUploadResponseSchema,
|
||||||
|
tagsOperationResultSchema
|
||||||
} from '@/platform/assets/schemas/assetSchema'
|
} from '@/platform/assets/schemas/assetSchema'
|
||||||
import type {
|
import type {
|
||||||
AssetItem,
|
AssetItem,
|
||||||
@@ -14,7 +15,8 @@ import type {
|
|||||||
AssetUpdatePayload,
|
AssetUpdatePayload,
|
||||||
AsyncUploadResponse,
|
AsyncUploadResponse,
|
||||||
ModelFile,
|
ModelFile,
|
||||||
ModelFolder
|
ModelFolder,
|
||||||
|
TagsOperationResult
|
||||||
} from '@/platform/assets/schemas/assetSchema'
|
} from '@/platform/assets/schemas/assetSchema'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||||
@@ -471,6 +473,66 @@ function createAssetService() {
|
|||||||
return await res.json()
|
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
|
* Uploads an asset asynchronously using the /api/assets/download endpoint
|
||||||
* Returns immediately with either the asset (if already exists) or a task to track
|
* Returns immediately with either the asset (if already exists) or a task to track
|
||||||
@@ -546,6 +608,8 @@ function createAssetService() {
|
|||||||
getAssetsByTag,
|
getAssetsByTag,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
updateAsset,
|
updateAsset,
|
||||||
|
addAssetTags,
|
||||||
|
removeAssetTags,
|
||||||
getAssetMetadata,
|
getAssetMetadata,
|
||||||
uploadAssetFromUrl,
|
uploadAssetFromUrl,
|
||||||
uploadAssetFromBase64,
|
uploadAssetFromBase64,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Button
|
<Button
|
||||||
:size
|
:size
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
:disabled="isPolling"
|
:disabled="disabled || isPolling"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:style="
|
:style="
|
||||||
variant === 'gradient'
|
variant === 'gradient'
|
||||||
@@ -32,12 +32,14 @@ const {
|
|||||||
size = 'lg',
|
size = 'lg',
|
||||||
fluid = true,
|
fluid = true,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
label
|
label,
|
||||||
|
disabled = false
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
label?: string
|
label?: string
|
||||||
size?: 'sm' | 'lg'
|
size?: 'sm' | 'lg'
|
||||||
variant?: 'default' | 'gradient'
|
variant?: 'default' | 'gradient'
|
||||||
fluid?: boolean
|
fluid?: boolean
|
||||||
|
disabled?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -17,208 +17,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grow overflow-auto">
|
<!-- Workspace mode: workspace-aware subscription content -->
|
||||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
|
||||||
<div>
|
<!-- Legacy mode: user-level subscription content -->
|
||||||
<div class="flex items-center justify-between gap-2">
|
<SubscriptionPanelContentLegacy 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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between border-t border-interface-stroke pt-3"
|
class="flex items-center justify-between border-t border-interface-stroke pt-3"
|
||||||
@@ -265,171 +67,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Skeleton from 'primevue/skeleton'
|
|
||||||
import TabPanel from 'primevue/tabpanel'
|
import TabPanel from 'primevue/tabpanel'
|
||||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
import { defineAsyncComponent } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
|
||||||
import { useExternalLink } from '@/composables/useExternalLink'
|
import { useExternalLink } from '@/composables/useExternalLink'
|
||||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
|
||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
|
||||||
import {
|
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
|
||||||
DEFAULT_TIER_KEY,
|
() =>
|
||||||
TIER_TO_KEY,
|
import('@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue')
|
||||||
getTierCredits,
|
)
|
||||||
getTierFeatures,
|
|
||||||
getTierPrice
|
const { flags } = useFeatureFlags()
|
||||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
|
||||||
|
|
||||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||||
const authActions = useFirebaseAuthActions()
|
|
||||||
const { t, n } = useI18n()
|
|
||||||
|
|
||||||
const {
|
const { isActiveSubscription, handleInvoiceHistory } = useSubscription()
|
||||||
isActiveSubscription,
|
|
||||||
isCancelled,
|
|
||||||
formattedRenewalDate,
|
|
||||||
formattedEndDate,
|
|
||||||
subscriptionTier,
|
|
||||||
subscriptionTierName,
|
|
||||||
subscriptionStatus,
|
|
||||||
isYearlySubscription,
|
|
||||||
handleInvoiceHistory
|
|
||||||
} = useSubscription()
|
|
||||||
|
|
||||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
|
||||||
|
useSubscriptionActions()
|
||||||
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 handleOpenPartnerNodesInfo = () => {
|
const handleOpenPartnerNodesInfo = () => {
|
||||||
window.open(
|
window.open(
|
||||||
@@ -438,9 +101,3 @@ const handleOpenPartnerNodesInfo = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</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', () => ({
|
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||||
useFirebaseAuthStore: vi.fn(() => ({
|
useFirebaseAuthStore: vi.fn(() => ({
|
||||||
getAuthHeader: mockGetAuthHeader
|
getFirebaseAuthHeader: mockGetAuthHeader
|
||||||
})),
|
})),
|
||||||
FirebaseAuthStoreError: class extends Error {}
|
FirebaseAuthStoreError: class extends Error {}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function useSubscriptionInternal() {
|
|||||||
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
||||||
const { showSubscriptionRequiredDialog } = useDialogService()
|
const { showSubscriptionRequiredDialog } = useDialogService()
|
||||||
|
|
||||||
const { getAuthHeader } = useFirebaseAuthStore()
|
const { getFirebaseAuthHeader } = useFirebaseAuthStore()
|
||||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||||
|
|
||||||
const { isLoggedIn } = useCurrentUser()
|
const { isLoggedIn } = useCurrentUser()
|
||||||
@@ -168,7 +168,7 @@ function useSubscriptionInternal() {
|
|||||||
* @returns Subscription status or null if no subscription exists
|
* @returns Subscription status or null if no subscription exists
|
||||||
*/
|
*/
|
||||||
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
|
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||||
}
|
}
|
||||||
@@ -217,7 +217,7 @@ function useSubscriptionInternal() {
|
|||||||
|
|
||||||
const initiateSubscriptionCheckout =
|
const initiateSubscriptionCheckout =
|
||||||
async (): Promise<CloudSubscriptionCheckoutResponse> => {
|
async (): Promise<CloudSubscriptionCheckoutResponse> => {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(
|
throw new FirebaseAuthStoreError(
|
||||||
t('toastMessages.userNotAuthenticated')
|
t('toastMessages.userNotAuthenticated')
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings-container">
|
<div
|
||||||
<ScrollPanel class="settings-sidebar w-48 shrink-0 p-2 2xl:w-64">
|
: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
|
<SearchBox
|
||||||
v-model:model-value="searchQuery"
|
v-model:model-value="searchQuery"
|
||||||
class="settings-search-box mb-2 w-full"
|
class="settings-search-box mb-2 w-full"
|
||||||
@@ -20,16 +32,40 @@
|
|||||||
(option: SettingTreeNode) =>
|
(option: SettingTreeNode) =>
|
||||||
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
|
!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" />
|
<Divider class="my-0" />
|
||||||
</template>
|
</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>
|
</Listbox>
|
||||||
</ScrollPanel>
|
</ScrollPanel>
|
||||||
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
|
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
|
||||||
<Divider layout="horizontal" class="flex md:hidden" />
|
<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">
|
<TabPanels class="settings-tab-panels h-full w-full pr-0">
|
||||||
<PanelTemplate value="Search Results">
|
<PanelTemplate value="Search Results">
|
||||||
<SettingsPanel :setting-groups="searchResults" />
|
<SettingsPanel :setting-groups="searchResults" />
|
||||||
@@ -48,7 +84,7 @@
|
|||||||
</PanelTemplate>
|
</PanelTemplate>
|
||||||
|
|
||||||
<Suspense v-for="panel in panels" :key="panel.node.key">
|
<Suspense v-for="panel in panels" :key="panel.node.key">
|
||||||
<component :is="panel.component" />
|
<component :is="panel.component" v-bind="panel.props" />
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
|
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -69,7 +105,10 @@ import { computed, watch } from 'vue'
|
|||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchBox from '@/components/common/SearchBox.vue'
|
||||||
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
|
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
|
||||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.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 { 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 ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||||
@@ -86,8 +125,15 @@ const { defaultPanel } = defineProps<{
|
|||||||
| 'server-config'
|
| 'server-config'
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'credits'
|
| 'credits'
|
||||||
|
| 'subscription'
|
||||||
|
| 'workspace'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
const teamWorkspacesEnabled = computed(
|
||||||
|
() => isCloud && flags.teamWorkspacesEnabled
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeCategory,
|
activeCategory,
|
||||||
defaultCategory,
|
defaultCategory,
|
||||||
@@ -162,6 +208,7 @@ watch(activeCategory, (_, oldValue) => {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
|
||||||
.settings-container {
|
.settings-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 70vh;
|
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)) {
|
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,21 @@ import type { Component } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import type { SettingParams } from '@/platform/settings/types'
|
import type { SettingParams } from '@/platform/settings/types'
|
||||||
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
import { isElectron } from '@/utils/envUtil'
|
import { isElectron } from '@/utils/envUtil'
|
||||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||||
import { buildTree } from '@/utils/treeUtil'
|
import { buildTree } from '@/utils/treeUtil'
|
||||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
|
||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
|
||||||
|
|
||||||
interface SettingPanelItem {
|
interface SettingPanelItem {
|
||||||
node: SettingTreeNode
|
node: SettingTreeNode
|
||||||
component: Component
|
component: Component
|
||||||
|
props?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSettingUI(
|
export function useSettingUI(
|
||||||
@@ -27,15 +29,21 @@ export function useSettingUI(
|
|||||||
| 'user'
|
| 'user'
|
||||||
| 'credits'
|
| 'credits'
|
||||||
| 'subscription'
|
| 'subscription'
|
||||||
|
| 'workspace'
|
||||||
) {
|
) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { isLoggedIn } = useCurrentUser()
|
const { isLoggedIn } = useCurrentUser()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||||
const { isActiveSubscription } = useSubscription()
|
const { isActiveSubscription } = useSubscription()
|
||||||
|
|
||||||
|
const teamWorkspacesEnabled = computed(
|
||||||
|
() => isCloud && flags.teamWorkspacesEnabled
|
||||||
|
)
|
||||||
|
|
||||||
const settingRoot = computed<SettingTreeNode>(() => {
|
const settingRoot = computed<SettingTreeNode>(() => {
|
||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
Object.values(settingStore.settingsById).filter(
|
Object.values(settingStore.settingsById).filter(
|
||||||
@@ -64,6 +72,33 @@ export function useSettingUI(
|
|||||||
() => settingRoot.value.children ?? []
|
() => 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
|
// Define panel items
|
||||||
const aboutPanel: SettingPanelItem = {
|
const aboutPanel: SettingPanelItem = {
|
||||||
node: {
|
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 = {
|
const keybindingPanel: SettingPanelItem = {
|
||||||
node: {
|
node: {
|
||||||
key: 'keybinding',
|
key: 'keybinding',
|
||||||
@@ -156,13 +207,14 @@ export function useSettingUI(
|
|||||||
aboutPanel,
|
aboutPanel,
|
||||||
creditsPanel,
|
creditsPanel,
|
||||||
userPanel,
|
userPanel,
|
||||||
|
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||||
keybindingPanel,
|
keybindingPanel,
|
||||||
extensionPanel,
|
extensionPanel,
|
||||||
...(isElectron() ? [serverConfigPanel] : []),
|
...(isElectron() ? [serverConfigPanel] : []),
|
||||||
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
|
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
|
||||||
? [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
|
// Account settings - show different panels based on distribution and auth state
|
||||||
{
|
{
|
||||||
key: 'account',
|
key: 'account',
|
||||||
@@ -223,6 +315,12 @@ export function useSettingUI(
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() =>
|
||||||
|
teamWorkspacesEnabled.value
|
||||||
|
? workspaceMenuTreeNodes.value
|
||||||
|
: legacyMenuTreeNodes.value
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
activeCategory.value = defaultCategory.value
|
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.
|
* Zendesk ticket form field IDs.
|
||||||
*/
|
*/
|
||||||
const ZENDESK_FIELDS = {
|
export const ZENDESK_FIELDS = {
|
||||||
/** Distribution tag (cloud vs OSS) */
|
/** Distribution tag (cloud vs OSS) */
|
||||||
DISTRIBUTION: 'tf_42243568391700',
|
DISTRIBUTION: 'tf_42243568391700',
|
||||||
/** User email (anonymous requester) */
|
/** User email (anonymous requester) */
|
||||||
@@ -14,6 +14,16 @@ const ZENDESK_FIELDS = {
|
|||||||
USER_ID: 'tf_42515251051412'
|
USER_ID: 'tf_42515251051412'
|
||||||
} as const
|
} 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'
|
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,7 +38,7 @@ export function buildSupportUrl(params?: {
|
|||||||
userId?: string | null
|
userId?: string | null
|
||||||
}): string {
|
}): string {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
[ZENDESK_FIELDS.DISTRIBUTION]: isCloud ? 'ccloud' : 'oss'
|
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (params?.userEmail) {
|
if (params?.userEmail) {
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ const useCaseFuse = new Fuse(USE_CASE_CATEGORIES, FUSE_OPTIONS)
|
|||||||
/**
|
/**
|
||||||
* Normalize industry responses using Fuse.js fuzzy search
|
* 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') {
|
if (!rawIndustry || typeof rawIndustry !== 'string') {
|
||||||
return 'Other / Undefined'
|
return 'Other / Undefined'
|
||||||
}
|
}
|
||||||
@@ -554,7 +554,7 @@ export function normalizeIndustry(rawIndustry: string): string {
|
|||||||
/**
|
/**
|
||||||
* Normalize use case responses using Fuse.js fuzzy search
|
* 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') {
|
if (!rawUseCase || typeof rawUseCase !== 'string') {
|
||||||
return 'Other / Undefined'
|
return 'Other / Undefined'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { t } from '@/i18n'
|
|||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
|
|
||||||
type WorkspaceType = 'personal' | 'team'
|
export type WorkspaceType = 'personal' | 'team'
|
||||||
type WorkspaceRole = 'owner' | 'member'
|
export type WorkspaceRole = 'owner' | 'member'
|
||||||
|
|
||||||
interface Workspace {
|
interface Workspace {
|
||||||
id: string
|
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)
|
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'))
|
mockWorkspaceApi.list.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
const store = useTeamWorkspaceStore()
|
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.initState).toBe('error')
|
||||||
expect(store.error).toBeInstanceOf(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 () => {
|
it('does not reinitialize if already initialized', async () => {
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ function setLastWorkspaceId(workspaceId: string): void {
|
|||||||
|
|
||||||
const MAX_OWNED_WORKSPACES = 10
|
const MAX_OWNED_WORKSPACES = 10
|
||||||
const MAX_WORKSPACE_MEMBERS = 50
|
const MAX_WORKSPACE_MEMBERS = 50
|
||||||
|
const MAX_INIT_RETRIES = 3
|
||||||
|
const BASE_RETRY_DELAY_MS = 1000
|
||||||
|
|
||||||
export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||||
const initState = ref<InitState>('uninitialized')
|
const initState = ref<InitState>('uninitialized')
|
||||||
@@ -174,6 +176,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
|||||||
* Initialize the workspace store.
|
* Initialize the workspace store.
|
||||||
* Fetches workspaces and resolves the active workspace from session/localStorage.
|
* Fetches workspaces and resolves the active workspace from session/localStorage.
|
||||||
* Delegates token management to workspaceAuthStore.
|
* Delegates token management to workspaceAuthStore.
|
||||||
|
* Retries on transient failures with exponential backoff.
|
||||||
* Call once on app boot.
|
* Call once on app boot.
|
||||||
*/
|
*/
|
||||||
async function initialize(): Promise<void> {
|
async function initialize(): Promise<void> {
|
||||||
@@ -185,60 +188,115 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
|||||||
|
|
||||||
const workspaceAuthStore = useWorkspaceAuthStore()
|
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||||
|
|
||||||
try {
|
for (let attempt = 0; attempt <= MAX_INIT_RETRIES; attempt++) {
|
||||||
// 1. Try to restore workspace context from session (page refresh case)
|
try {
|
||||||
const hasValidSession = workspaceAuthStore.initializeFromSession()
|
// 1. Try to restore workspace context from session (page refresh case)
|
||||||
|
const hasValidSession = workspaceAuthStore.initializeFromSession()
|
||||||
|
|
||||||
if (hasValidSession && workspaceAuthStore.currentWorkspace) {
|
if (hasValidSession && workspaceAuthStore.currentWorkspace) {
|
||||||
// Valid session exists - fetch workspace list and sync state
|
// 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()
|
const response = await workspaceApi.list()
|
||||||
workspaces.value = response.workspaces.map(createWorkspaceState)
|
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'
|
initState.value = 'ready'
|
||||||
|
isFetchingWorkspaces.value = false
|
||||||
return
|
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 EditableText from '@/components/common/EditableText.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||||
|
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||||
import { st } from '@/i18n'
|
import { st } from '@/i18n'
|
||||||
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
|
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
|
||||||
@@ -183,9 +184,67 @@ const statusBadge = computed((): NodeBadgeProps | undefined =>
|
|||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
const nodeBadges = computed<NodeBadgeProps[]>(() =>
|
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
|
||||||
[...(nodeData?.badges ?? [])].map(toValue)
|
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 isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
|
||||||
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
|
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
|
||||||
|
|
||||||
|
|||||||
@@ -159,14 +159,15 @@ function handleMouseMove(e: PointerEvent) {
|
|||||||
function handleMouseUp() {
|
function handleMouseUp() {
|
||||||
const newValue = dragValue.value
|
const newValue = dragValue.value
|
||||||
if (newValue === undefined) return
|
if (newValue === undefined) return
|
||||||
modelValue.value = newValue
|
|
||||||
dragValue.value = undefined
|
|
||||||
|
|
||||||
if (dragDelta.value === 0) {
|
if (newValue === modelValue.value) {
|
||||||
textEdit.value = true
|
textEdit.value = true
|
||||||
inputField.value?.focus()
|
inputField.value?.focus()
|
||||||
inputField.value?.setSelectionRange(0, -1)
|
inputField.value?.setSelectionRange(0, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modelValue.value = newValue
|
||||||
|
dragValue.value = undefined
|
||||||
dragDelta.value = 0
|
dragDelta.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,9 +203,13 @@ const sliderWidth = computed(() => {
|
|||||||
:class="cn(WidgetInputBaseClass, 'grow text-xs flex h-7 relative')"
|
:class="cn(WidgetInputBaseClass, 'grow text-xs flex h-7 relative')"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-primary-background/15 absolute left-0 bottom-0 h-full rounded-lg pointer-events-none"
|
class="absolute size-full rounded-lg pointer-events-none overflow-clip"
|
||||||
:style="{ width: `${sliderWidth}%` }"
|
>
|
||||||
/>
|
<div
|
||||||
|
class="bg-primary-background/15 size-full"
|
||||||
|
:style="{ width: `${sliderWidth}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="!buttonsDisabled"
|
v-if="!buttonsDisabled"
|
||||||
data-testid="decrement"
|
data-testid="decrement"
|
||||||
|
|||||||
@@ -197,6 +197,50 @@ const zComfyOutputTypesSpec = z.array(
|
|||||||
z.union([zComfyNodeDataType, zComfyComboOutput])
|
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({
|
export const zComfyNodeDef = z.object({
|
||||||
input: zComfyInputsSpec.optional(),
|
input: zComfyInputsSpec.optional(),
|
||||||
output: zComfyOutputTypesSpec.optional(),
|
output: zComfyOutputTypesSpec.optional(),
|
||||||
@@ -224,7 +268,18 @@ export const zComfyNodeDef = z.object({
|
|||||||
* Used to ensure consistent widget ordering regardless of JSON serialization.
|
* Used to ensure consistent widget ordering regardless of JSON serialization.
|
||||||
* Keys are 'required', 'optional', etc., values are arrays of input names.
|
* 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({
|
export const zAutogrowOptions = z.object({
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export class ComfyApp {
|
|||||||
|
|
||||||
// TODO: Migrate internal usage to the
|
// TODO: Migrate internal usage to the
|
||||||
/** @deprecated Use {@link rootGraph} instead */
|
/** @deprecated Use {@link rootGraph} instead */
|
||||||
get graph(): unknown {
|
get graph(): LGraph | undefined {
|
||||||
return this.rootGraphInternal!
|
return this.rootGraphInternal!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const useDialogService = () => {
|
|||||||
| 'user'
|
| 'user'
|
||||||
| 'credits'
|
| 'credits'
|
||||||
| 'subscription'
|
| 'subscription'
|
||||||
|
| 'workspace'
|
||||||
) {
|
) {
|
||||||
const props = panel ? { props: { defaultPanel: panel } } : undefined
|
const props = panel ? { props: { defaultPanel: panel } } : undefined
|
||||||
|
|
||||||
@@ -519,6 +520,75 @@ export const useDialogService = () => {
|
|||||||
show()
|
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 {
|
return {
|
||||||
showLoadWorkflowWarning,
|
showLoadWorkflowWarning,
|
||||||
showMissingModelsWarning,
|
showMissingModelsWarning,
|
||||||
@@ -536,6 +606,10 @@ export const useDialogService = () => {
|
|||||||
confirm,
|
confirm,
|
||||||
showLayoutDialog,
|
showLayoutDialog,
|
||||||
showImportFailedNodeDialog,
|
showImportFailedNodeDialog,
|
||||||
showNodeConflictDialog
|
showNodeConflictDialog,
|
||||||
|
showDeleteWorkspaceDialog,
|
||||||
|
showCreateWorkspaceDialog,
|
||||||
|
showLeaveWorkspaceDialog,
|
||||||
|
showEditWorkspaceDialog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class NodeSearchService {
|
|||||||
constructor(data: ComfyNodeDefImpl[]) {
|
constructor(data: ComfyNodeDefImpl[]) {
|
||||||
this.nodeFuseSearch = new FuseSearch(data, {
|
this.nodeFuseSearch = new FuseSearch(data, {
|
||||||
fuseOptions: {
|
fuseOptions: {
|
||||||
keys: ['name', 'display_name'],
|
keys: ['name', 'display_name', 'search_aliases'],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
shouldSort: false,
|
shouldSort: false,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useAsyncState, whenever } from '@vueuse/core'
|
import { useAsyncState, whenever } from '@vueuse/core'
|
||||||
|
import { difference } from 'es-toolkit'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, reactive, ref, shallowReactive } from 'vue'
|
import { computed, reactive, ref, shallowReactive } from 'vue'
|
||||||
import {
|
import {
|
||||||
@@ -455,32 +456,71 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update asset metadata with optimistic cache update
|
* 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 userMetadata The user_metadata to save
|
||||||
* @param cacheKey Optional cache key to target for optimistic update
|
* @param cacheKey Optional cache key to target for optimistic update
|
||||||
*/
|
*/
|
||||||
async function updateAssetMetadata(
|
async function updateAssetMetadata(
|
||||||
assetId: string,
|
asset: AssetItem,
|
||||||
userMetadata: Record<string, unknown>,
|
userMetadata: Record<string, unknown>,
|
||||||
cacheKey?: string
|
cacheKey?: string
|
||||||
) {
|
) {
|
||||||
updateAssetInCache(assetId, { user_metadata: userMetadata }, cacheKey)
|
const originalMetadata = asset.user_metadata
|
||||||
await assetService.updateAsset(assetId, { user_metadata: userMetadata })
|
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
|
* Update asset tags using add/remove endpoints
|
||||||
* @param assetId The asset ID to update
|
* @param asset The asset to update (used to read current tags)
|
||||||
* @param tags The tags array to save
|
* @param newTags The desired tags array
|
||||||
* @param cacheKey Optional cache key to target for optimistic update
|
* @param cacheKey Optional cache key to target for optimistic update
|
||||||
*/
|
*/
|
||||||
async function updateAssetTags(
|
async function updateAssetTags(
|
||||||
assetId: string,
|
asset: AssetItem,
|
||||||
tags: string[],
|
newTags: string[],
|
||||||
cacheKey?: string
|
cacheKey?: string
|
||||||
) {
|
) {
|
||||||
updateAssetInCache(assetId, { tags }, cacheKey)
|
const originalTags = asset.tags
|
||||||
await assetService.updateAsset(assetId, { 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 {
|
return {
|
||||||
|
|||||||
@@ -512,6 +512,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
sendPasswordReset,
|
sendPasswordReset,
|
||||||
updatePassword: _updatePassword,
|
updatePassword: _updatePassword,
|
||||||
deleteAccount: _deleteAccount,
|
deleteAccount: _deleteAccount,
|
||||||
getAuthHeader
|
getAuthHeader,
|
||||||
|
getFirebaseAuthHeader
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
ComfyInputsSpec as ComfyInputSpecV1,
|
ComfyInputsSpec as ComfyInputSpecV1,
|
||||||
ComfyNodeDef as ComfyNodeDefV1,
|
ComfyNodeDef as ComfyNodeDefV1,
|
||||||
ComfyOutputTypesSpec as ComfyOutputSpecV1
|
ComfyOutputTypesSpec as ComfyOutputSpecV1,
|
||||||
|
PriceBadge
|
||||||
} from '@/schemas/nodeDefSchema'
|
} from '@/schemas/nodeDefSchema'
|
||||||
import { NodeSearchService } from '@/services/nodeSearchService'
|
import { NodeSearchService } from '@/services/nodeSearchService'
|
||||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||||
@@ -66,6 +67,12 @@ export class ComfyNodeDefImpl
|
|||||||
* Order of inputs for each category (required, optional, hidden)
|
* Order of inputs for each category (required, optional, hidden)
|
||||||
*/
|
*/
|
||||||
readonly input_order?: Record<string, string[]>
|
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
|
// V2 fields
|
||||||
readonly inputs: Record<string, InputSpecV2>
|
readonly inputs: Record<string, InputSpecV2>
|
||||||
@@ -134,6 +141,7 @@ export class ComfyNodeDefImpl
|
|||||||
this.output_name = obj.output_name
|
this.output_name = obj.output_name
|
||||||
this.output_tooltips = obj.output_tooltips
|
this.output_tooltips = obj.output_tooltips
|
||||||
this.input_order = obj.input_order
|
this.input_order = obj.input_order
|
||||||
|
this.price_badge = obj.price_badge
|
||||||
|
|
||||||
// Initialize V2 fields
|
// Initialize V2 fields
|
||||||
const defV2 = transformNodeDefV1ToV2(obj)
|
const defV2 = transformNodeDefV1ToV2(obj)
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ export type {
|
|||||||
ToastMessageOptions
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
/** For use by extensions and in the browser console. Where possible, import `app` from '@/scripts/app' instead. */
|
/** 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. */
|
/** For use by extensions and in the browser console. Where possible, import `app` and access via `app.graph` instead. */
|
||||||
graph?: unknown
|
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 { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||||
import { i18n, loadLocale } from '@/i18n'
|
import { i18n, loadLocale } from '@/i18n'
|
||||||
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
|
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
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(() => {
|
onMounted(() => {
|
||||||
api.addEventListener('status', onStatus)
|
api.addEventListener('status', onStatus)
|
||||||
api.addEventListener('execution_success', onExecutionSuccess)
|
api.addEventListener('execution_success', onExecutionSuccess)
|
||||||
@@ -352,7 +374,10 @@ const onGraphReady = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Broadcast our heartbeat
|
// Broadcast our heartbeat
|
||||||
tabCountChannel?.postMessage({ type: 'heartbeat', tabId: currentTabId })
|
tabCountChannel?.postMessage({
|
||||||
|
type: 'heartbeat',
|
||||||
|
tabId: currentTabId
|
||||||
|
})
|
||||||
|
|
||||||
// Track tab count (include current tab)
|
// Track tab count (include current tab)
|
||||||
const tabCount = activeTabs.size + 1
|
const tabCount = activeTabs.size + 1
|
||||||
|
|||||||
Reference in New Issue
Block a user