Compare commits
50 Commits
fix/qwenvl
...
cloud/1.37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9ede1fbb | ||
|
|
72add79ec3 | ||
|
|
8e5a037ccd | ||
|
|
4c2edae5a5 | ||
|
|
a5f50ac91b | ||
|
|
e751cf42a2 | ||
|
|
e82c6928e9 | ||
|
|
1963f28429 | ||
|
|
3b7e102b52 | ||
|
|
3eb15bb489 | ||
|
|
ec91aa85e5 | ||
|
|
745ea0aab0 | ||
|
|
113a6a7249 | ||
|
|
4c3c61fcfe | ||
|
|
12761f83be | ||
|
|
7a5fb57aa0 | ||
|
|
9b7f20c3bb | ||
|
|
9db2fd8884 | ||
|
|
88f7886297 | ||
|
|
b98d53e740 | ||
|
|
eb7d0c7c1a | ||
|
|
2d0f3d6a55 | ||
|
|
5d94c117c0 | ||
|
|
0c3d569ece | ||
|
|
7faf8e0ffd | ||
|
|
b8a103b30e | ||
|
|
32ce523d67 | ||
|
|
a6da367921 | ||
|
|
06bc1032a6 | ||
|
|
14a22083b2 | ||
|
|
751253f6cd | ||
|
|
abb2b15ea1 | ||
|
|
f074243dda | ||
|
|
b7ddd50cd8 | ||
|
|
def9b55e07 | ||
|
|
995906a109 | ||
|
|
05cbccefe0 | ||
|
|
a55cae531f | ||
|
|
e036d7625a | ||
|
|
3eb8c6a347 | ||
|
|
ac6adb0b3f | ||
|
|
5a276f2e04 | ||
|
|
40b0954766 | ||
|
|
a3cd6304a8 | ||
|
|
9132f8725f | ||
|
|
d85c46901d | ||
|
|
cd6047fa89 | ||
|
|
3c99e75fe0 | ||
|
|
5ec29f64b6 | ||
|
|
c77f0cba45 |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
@@ -9,7 +9,7 @@ test.describe('Properties panel', () => {
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText(
|
||||
'No node(s) selected'
|
||||
'No item(s) selected'
|
||||
)
|
||||
|
||||
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
|
||||
|
||||
@@ -82,9 +82,7 @@ test.describe('Templates', () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await comfyPage.page
|
||||
.locator(
|
||||
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
|
||||
)
|
||||
.getByRole('button', { name: 'Getting Started' })
|
||||
.click()
|
||||
await comfyPage.templates.loadTemplate('default')
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
@@ -189,9 +187,7 @@ test.describe('Templates', () => {
|
||||
const templateGrid = comfyPage.page.locator(
|
||||
'[data-testid="template-workflows-content"]'
|
||||
)
|
||||
const nav = comfyPage.page
|
||||
.locator('header')
|
||||
.filter({ hasText: 'Templates' })
|
||||
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
|
||||
|
||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
@@ -201,7 +197,8 @@ test.describe('Templates', () => {
|
||||
await comfyPage.page.setViewportSize(mobileSize)
|
||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
|
||||
// Nav header is clipped by overflow-hidden parent at mobile size
|
||||
await expect(nav).not.toBeInViewport()
|
||||
|
||||
const tabletSize = { width: 1024, height: 800 }
|
||||
await comfyPage.page.setViewportSize(tabletSize)
|
||||
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -194,7 +194,10 @@ test.describe('Image widget', () => {
|
||||
const comboEntry = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'image32x32.webp'
|
||||
})
|
||||
await comboEntry.click({ noWaitAfter: true })
|
||||
await comboEntry.click()
|
||||
|
||||
// Stabilization for the image swap
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 46 KiB |
@@ -30,6 +30,10 @@ describe('MyStore', () => {
|
||||
|
||||
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
||||
|
||||
## i18n in Component Tests
|
||||
|
||||
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
|
||||
|
||||
## Mock Patterns
|
||||
|
||||
### Reset all mocks at once
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
--inverted-background-hover: var(--color-charcoal-600);
|
||||
--warning-background: var(--color-gold-400);
|
||||
--warning-background-hover: var(--color-gold-500);
|
||||
--success-background: var(--color-jade-600);
|
||||
--border-default: var(--color-smoke-600);
|
||||
--border-subtle: var(--color-smoke-400);
|
||||
--muted-background: var(--color-smoke-700);
|
||||
@@ -281,7 +282,7 @@
|
||||
--modal-card-border-highlighted: var(--secondary-background-selected);
|
||||
--modal-card-button-surface: var(--color-smoke-300);
|
||||
--modal-card-placeholder-background: var(--color-smoke-600);
|
||||
--modal-card-tag-background: var(--color-smoke-400);
|
||||
--modal-card-tag-background: var(--color-smoke-200);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-white);
|
||||
}
|
||||
@@ -372,6 +373,7 @@
|
||||
--inverted-background-hover: var(--color-smoke-200);
|
||||
--warning-background: var(--color-gold-600);
|
||||
--warning-background-hover: var(--color-gold-500);
|
||||
--success-background: var(--color-jade-600);
|
||||
--border-default: var(--color-charcoal-200);
|
||||
--border-subtle: var(--color-charcoal-300);
|
||||
--muted-background: var(--color-charcoal-100);
|
||||
@@ -516,6 +518,7 @@
|
||||
--color-inverted-background-hover: var(--inverted-background-hover);
|
||||
--color-warning-background: var(--warning-background);
|
||||
--color-warning-background-hover: var(--warning-background-hover);
|
||||
--color-success-background: var(--success-background);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-muted-background: var(--muted-background);
|
||||
|
||||
206
src/components/auth/WorkspaceAuthGate.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import WorkspaceAuthGate from './WorkspaceAuthGate.vue'
|
||||
|
||||
const mockIsInitialized = ref(false)
|
||||
const mockCurrentUser = ref<object | null>(null)
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
isInitialized: mockIsInitialized,
|
||||
currentUser: mockCurrentUser
|
||||
})
|
||||
}))
|
||||
|
||||
const mockRefreshRemoteConfig = vi.fn()
|
||||
vi.mock('@/platform/remoteConfig/refreshRemoteConfig', () => ({
|
||||
refreshRemoteConfig: (options: unknown) => mockRefreshRemoteConfig(options)
|
||||
}))
|
||||
|
||||
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const mockWorkspaceStoreInitialize = vi.fn()
|
||||
const mockWorkspaceStoreInitState = vi.hoisted(() => ({
|
||||
value: 'uninitialized' as string
|
||||
}))
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get initState() {
|
||||
return mockWorkspaceStoreInitState.value
|
||||
},
|
||||
initialize: mockWorkspaceStoreInitialize
|
||||
})
|
||||
}))
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/progressspinner', () => ({
|
||||
default: { template: '<div class="progress-spinner" />' }
|
||||
}))
|
||||
|
||||
describe('WorkspaceAuthGate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsInitialized.value = false
|
||||
mockCurrentUser.value = null
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockWorkspaceStoreInitState.value = 'uninitialized'
|
||||
mockRefreshRemoteConfig.mockResolvedValue(undefined)
|
||||
mockWorkspaceStoreInitialize.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(WorkspaceAuthGate, {
|
||||
slots: {
|
||||
default: '<div data-testid="slot-content">App Content</div>'
|
||||
}
|
||||
})
|
||||
|
||||
describe('non-cloud builds', () => {
|
||||
it('renders slot immediately when isCloud is false', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(false)
|
||||
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud builds - unauthenticated user', () => {
|
||||
it('shows spinner while waiting for Firebase auth', () => {
|
||||
mockIsInitialized.value = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders slot when Firebase initializes with no user', async () => {
|
||||
mockIsInitialized.value = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
|
||||
|
||||
mockIsInitialized.value = true
|
||||
mockCurrentUser.value = null
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud builds - authenticated user', () => {
|
||||
beforeEach(() => {
|
||||
mockIsInitialized.value = true
|
||||
mockCurrentUser.value = { uid: 'user-123' }
|
||||
})
|
||||
|
||||
it('refreshes remote config with auth after Firebase init', async () => {
|
||||
mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRefreshRemoteConfig).toHaveBeenCalledWith({ useAuth: true })
|
||||
})
|
||||
|
||||
it('renders slot when teamWorkspacesEnabled is false', async () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
expect(mockWorkspaceStoreInitialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('initializes workspace store when teamWorkspacesEnabled is true', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWorkspaceStoreInitialize).toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('skips workspace init when store is already initialized', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockWorkspaceStoreInitState.value = 'ready'
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWorkspaceStoreInitialize).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling - graceful degradation', () => {
|
||||
beforeEach(() => {
|
||||
mockIsInitialized.value = true
|
||||
mockCurrentUser.value = { uid: 'user-123' }
|
||||
})
|
||||
|
||||
it('renders slot when remote config refresh fails', async () => {
|
||||
mockRefreshRemoteConfig.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders slot when remote config refresh times out', async () => {
|
||||
vi.useFakeTimers()
|
||||
// Never-resolving promise simulates a hanging request
|
||||
mockRefreshRemoteConfig.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
// Still showing spinner before timeout
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
|
||||
|
||||
// Advance past the 10 second timeout
|
||||
await vi.advanceTimersByTimeAsync(10_001)
|
||||
await flushPromises()
|
||||
|
||||
// Should render slot after timeout (graceful degradation)
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders slot when workspace store initialization fails', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockWorkspaceStoreInitialize.mockRejectedValue(
|
||||
new Error('Workspace init failed')
|
||||
)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
129
src/components/auth/WorkspaceAuthGate.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<slot v-if="isReady" />
|
||||
<div
|
||||
v-else
|
||||
class="fixed inset-0 z-[1100] flex items-center justify-center bg-[var(--p-mask-background)]"
|
||||
>
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* WorkspaceAuthGate - Conditional auth checkpoint for workspace mode.
|
||||
*
|
||||
* This gate ensures proper initialization order for workspace-scoped auth:
|
||||
* 1. Wait for Firebase auth to resolve
|
||||
* 2. Check if teamWorkspacesEnabled feature flag is on
|
||||
* 3. If YES: Initialize workspace token and store before rendering
|
||||
* 4. If NO: Render immediately using existing Firebase auth
|
||||
*
|
||||
* This prevents race conditions where API calls use Firebase tokens
|
||||
* instead of workspace tokens when the workspace feature is enabled.
|
||||
*/
|
||||
import { promiseTimeout, until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const FIREBASE_INIT_TIMEOUT_MS = 16_000
|
||||
const CONFIG_REFRESH_TIMEOUT_MS = 10_000
|
||||
|
||||
const isReady = ref(!isCloud)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const { isInitialized, currentUser } = storeToRefs(authStore)
|
||||
|
||||
try {
|
||||
// Step 1: Wait for Firebase auth to resolve
|
||||
// This is shared with router guard - both wait for the same thing,
|
||||
// but this gate blocks rendering while router guard blocks navigation
|
||||
if (!isInitialized.value) {
|
||||
await until(isInitialized).toBe(true, {
|
||||
timeout: FIREBASE_INIT_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2: If not authenticated, nothing more to do
|
||||
// Unauthenticated users don't have workspace context
|
||||
if (!currentUser.value) {
|
||||
isReady.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Refresh feature flags with auth context
|
||||
// This ensures teamWorkspacesEnabled reflects the authenticated user's state
|
||||
// Timeout prevents hanging if server is slow/unresponsive
|
||||
try {
|
||||
await Promise.race([
|
||||
refreshRemoteConfig({ useAuth: true }),
|
||||
promiseTimeout(CONFIG_REFRESH_TIMEOUT_MS).then(() => {
|
||||
throw new Error('Config refresh timeout')
|
||||
})
|
||||
])
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[WorkspaceAuthGate] Failed to refresh remote config:',
|
||||
error
|
||||
)
|
||||
// Continue - feature flags will use defaults (teamWorkspacesEnabled=false)
|
||||
// App will render with Firebase auth fallback
|
||||
}
|
||||
|
||||
// Step 4: THE CHECKPOINT - Are we in workspace mode?
|
||||
const { flags } = useFeatureFlags()
|
||||
if (!flags.teamWorkspacesEnabled) {
|
||||
// Not in workspace mode - use existing Firebase auth flow
|
||||
// No additional initialization needed
|
||||
isReady.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Step 5: WORKSPACE MODE - Full initialization
|
||||
await initializeWorkspaceMode()
|
||||
} catch (error) {
|
||||
console.error('[WorkspaceAuthGate] Initialization failed:', error)
|
||||
} finally {
|
||||
// Always render (graceful degradation)
|
||||
// If workspace init failed, API calls fall back to Firebase token
|
||||
isReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeWorkspaceMode(): Promise<void> {
|
||||
// Initialize the full workspace store which handles:
|
||||
// - Restoring workspace token from session (fast path for refresh)
|
||||
// - Fetching workspace list
|
||||
// - Switching to last used workspace if needed
|
||||
// - Setting active workspace
|
||||
try {
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
if (workspaceStore.initState === 'uninitialized') {
|
||||
await workspaceStore.initialize()
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't block - workspace UI features may not work but app will render
|
||||
// API calls will fall back to Firebase token
|
||||
console.warn(
|
||||
'[WorkspaceAuthGate] Failed to initialize workspace store:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on mount. This gate should be placed on the authenticated layout
|
||||
// (LayoutDefault) so it mounts fresh after login and unmounts on logout.
|
||||
// The router guard ensures only authenticated users reach this layout.
|
||||
onMounted(() => {
|
||||
void initialize()
|
||||
})
|
||||
</script>
|
||||
@@ -51,7 +51,7 @@ describe('EditableText', () => {
|
||||
isEditing: true
|
||||
})
|
||||
await wrapper.findComponent(InputText).setValue('New Text')
|
||||
await wrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
await wrapper.findComponent(InputText).trigger('keydown.enter')
|
||||
// Blur event should have been triggered
|
||||
expect(wrapper.findComponent(InputText).element).not.toBe(
|
||||
document.activeElement
|
||||
@@ -79,7 +79,7 @@ describe('EditableText', () => {
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
await wrapper.findComponent(InputText).trigger('keydown.escape')
|
||||
|
||||
// Should emit cancel event
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
@@ -103,7 +103,7 @@ describe('EditableText', () => {
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape (which triggers blur internally)
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
await wrapper.findComponent(InputText).trigger('keydown.escape')
|
||||
|
||||
// Manually trigger blur to simulate the blur that happens after escape
|
||||
await wrapper.findComponent(InputText).trigger('blur')
|
||||
@@ -120,7 +120,7 @@ describe('EditableText', () => {
|
||||
isEditing: true
|
||||
})
|
||||
await enterWrapper.findComponent(InputText).setValue('Saved Text')
|
||||
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
await enterWrapper.findComponent(InputText).trigger('keydown.enter')
|
||||
// Trigger blur that happens after enter
|
||||
await enterWrapper.findComponent(InputText).trigger('blur')
|
||||
expect(enterWrapper.emitted('edit')).toBeTruthy()
|
||||
@@ -133,7 +133,7 @@ describe('EditableText', () => {
|
||||
isEditing: true
|
||||
})
|
||||
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
|
||||
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
await escapeWrapper.findComponent(InputText).trigger('keydown.escape')
|
||||
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
|
||||
expect(escapeWrapper.emitted('edit')).toBeFalsy()
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span v-if="!isEditing">
|
||||
{{ modelValue }}
|
||||
</span>
|
||||
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
|
||||
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||
<InputText
|
||||
v-else
|
||||
ref="inputRef"
|
||||
@@ -18,8 +18,8 @@
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keyup.enter.capture.stop="blurInputElement"
|
||||
@keyup.escape.stop="cancelEditing"
|
||||
@keydown.enter.capture.stop="blurInputElement"
|
||||
@keydown.escape.capture.stop="cancelEditing"
|
||||
@click.stop
|
||||
@contextmenu.stop
|
||||
@pointerdown.stop.capture
|
||||
|
||||
95
src/components/common/StatusBadge.stories.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Common/StatusBadge',
|
||||
component: StatusBadge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
severity: {
|
||||
control: 'select',
|
||||
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['label', 'dot', 'circle']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
label: 'Status',
|
||||
severity: 'default'
|
||||
}
|
||||
} satisfies Meta<typeof StatusBadge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
label: 'Failed',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Finished: Story = {
|
||||
args: {
|
||||
label: 'Finished',
|
||||
severity: 'contrast'
|
||||
}
|
||||
}
|
||||
|
||||
export const Dot: Story = {
|
||||
args: {
|
||||
label: undefined,
|
||||
variant: 'dot',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Circle: Story = {
|
||||
args: {
|
||||
label: '3',
|
||||
variant: 'circle'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSeverities: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge label="Default" severity="default" />
|
||||
<StatusBadge label="Secondary" severity="secondary" />
|
||||
<StatusBadge label="Warn" severity="warn" />
|
||||
<StatusBadge label="Danger" severity="danger" />
|
||||
<StatusBadge label="Contrast" severity="contrast" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="Label" variant="label" />
|
||||
<span class="text-xs text-muted">label</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge variant="dot" severity="danger" />
|
||||
<span class="text-xs text-muted">dot</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="5" variant="circle" />
|
||||
<span class="text-xs text-muted">circle</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,30 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
|
||||
import { statusBadgeVariants } from './statusBadge.variants'
|
||||
import type { StatusBadgeVariants } from './statusBadge.variants'
|
||||
|
||||
const { label, severity = 'default' } = defineProps<{
|
||||
label: string
|
||||
severity?: Severity
|
||||
const {
|
||||
label,
|
||||
severity = 'default',
|
||||
variant
|
||||
} = defineProps<{
|
||||
label?: string | number
|
||||
severity?: StatusBadgeVariants['severity']
|
||||
variant?: StatusBadgeVariants['variant']
|
||||
}>()
|
||||
|
||||
function badgeClasses(sev: Severity): string {
|
||||
const baseClasses =
|
||||
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
|
||||
|
||||
switch (sev) {
|
||||
case 'danger':
|
||||
return `${baseClasses} bg-destructive-background text-white`
|
||||
case 'contrast':
|
||||
return `${baseClasses} bg-base-foreground text-base-background`
|
||||
case 'warn':
|
||||
return `${baseClasses} bg-warning-background text-base-background`
|
||||
case 'secondary':
|
||||
return `${baseClasses} bg-secondary-background text-base-foreground`
|
||||
default:
|
||||
return `${baseClasses} bg-primary-background text-base-foreground`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeClasses(severity)">{{ label }}</span>
|
||||
<span
|
||||
:class="
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<template>
|
||||
<div ref="container" class="scroll-container">
|
||||
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
|
||||
<div :style="gridStyle">
|
||||
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
<div
|
||||
v-for="item in renderedItems"
|
||||
:key="item.key"
|
||||
class="transition-[width] duration-150 ease-out"
|
||||
data-virtual-grid-item
|
||||
>
|
||||
<slot name="item" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
height: `${((items.length - state.end) / cols) * itemHeight}px`
|
||||
}"
|
||||
/>
|
||||
<div :style="bottomSpacerStyle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,19 +32,22 @@ type GridState = {
|
||||
|
||||
const {
|
||||
items,
|
||||
gridStyle,
|
||||
bufferRows = 1,
|
||||
scrollThrottle = 64,
|
||||
resizeDebounce = 64,
|
||||
defaultItemHeight = 200,
|
||||
defaultItemWidth = 200
|
||||
defaultItemWidth = 200,
|
||||
maxColumns = Infinity
|
||||
} = defineProps<{
|
||||
items: (T & { key: string })[]
|
||||
gridStyle: Partial<CSSProperties>
|
||||
gridStyle: CSSProperties
|
||||
bufferRows?: number
|
||||
scrollThrottle?: number
|
||||
resizeDebounce?: number
|
||||
defaultItemHeight?: number
|
||||
defaultItemWidth?: number
|
||||
maxColumns?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -59,7 +66,18 @@ const { y: scrollY } = useScroll(container, {
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
|
||||
const cols = computed(() =>
|
||||
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
|
||||
)
|
||||
|
||||
const mergedGridStyle = computed<CSSProperties>(() => {
|
||||
if (maxColumns === Infinity) return gridStyle
|
||||
return {
|
||||
...gridStyle,
|
||||
gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
|
||||
}
|
||||
})
|
||||
|
||||
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
|
||||
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
|
||||
const isValidGrid = computed(() => height.value && width.value && items?.length)
|
||||
@@ -83,6 +101,16 @@ const renderedItems = computed(() =>
|
||||
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
||||
)
|
||||
|
||||
function rowsToHeight(rows: number): string {
|
||||
return `${(rows / cols.value) * itemHeight.value}px`
|
||||
}
|
||||
const topSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(state.value.start)
|
||||
}))
|
||||
const bottomSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(items.length - state.value.end)
|
||||
}))
|
||||
|
||||
whenever(
|
||||
() => state.value.isNearEnd,
|
||||
() => {
|
||||
@@ -109,15 +137,6 @@ const onResize = debounce(updateItemSize, resizeDebounce)
|
||||
watch([width, height], onResize, { flush: 'post' })
|
||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||
onBeforeUnmount(() => {
|
||||
onResize.cancel() // Clear pending debounced calls
|
||||
onResize.cancel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--dialog-surface) transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
43
src/components/common/WorkspaceProfilePic.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-8 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>
|
||||
26
src/components/common/statusBadge.variants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const statusBadgeVariants = cva({
|
||||
base: 'inline-flex items-center justify-center rounded-full',
|
||||
variants: {
|
||||
severity: {
|
||||
default: 'bg-primary-background text-base-foreground',
|
||||
secondary: 'bg-secondary-background text-base-foreground',
|
||||
warn: 'bg-warning-background text-base-background',
|
||||
danger: 'bg-destructive-background text-white',
|
||||
contrast: 'bg-base-foreground text-base-background'
|
||||
},
|
||||
variant: {
|
||||
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
|
||||
dot: 'size-2',
|
||||
circle: 'size-3.5 text-xxxs font-semibold'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
severity: 'default',
|
||||
variant: 'label'
|
||||
}
|
||||
})
|
||||
|
||||
export type StatusBadgeVariants = VariantProps<typeof statusBadgeVariants>
|
||||
@@ -3,17 +3,14 @@
|
||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||
class="workflow-template-selector-dialog"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[comfy--template]" />
|
||||
<h2 class="text-neutral text-base">
|
||||
{{ $t('sideToolbar.templates', 'Templates') }}
|
||||
</h2>
|
||||
</template>
|
||||
<template #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[comfy--template]" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">{{
|
||||
$t('sideToolbar.templates', 'Templates')
|
||||
}}</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems" />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="item.dialogComponentProps.pt"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
@@ -36,11 +41,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
}): DialogPassThroughOptions {
|
||||
const isWorkspaceSettingsDialog =
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled.value
|
||||
const basePt = item.dialogComponentProps.pt || {}
|
||||
|
||||
if (isWorkspaceSettingsDialog) {
|
||||
return merge(basePt, {
|
||||
mask: { class: 'p-8' }
|
||||
})
|
||||
}
|
||||
return basePt
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -56,16 +88,16 @@ const dialogStore = useDialogStore()
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
/* Workspace mode: wider settings dialog */
|
||||
.settings-dialog-workspace {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
.settings-dialog-workspace .p-dialog-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,7 +31,12 @@
|
||||
}}</label>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" autofocus @click="onCancel">
|
||||
<Button
|
||||
v-if="type !== 'info'"
|
||||
variant="secondary"
|
||||
autofocus
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-undo" />
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
@@ -73,6 +78,10 @@
|
||||
<i class="pi pi-eraser" />
|
||||
{{ $t('desktopMenu.reinstall') }}
|
||||
</Button>
|
||||
<!-- Info - just show an OK button -->
|
||||
<Button v-else-if="type === 'info'" variant="primary" @click="onCancel">
|
||||
{{ $t('g.ok') }}
|
||||
</Button>
|
||||
<!-- Invalid - just show a close button. -->
|
||||
<Button v-else variant="primary" @click="onCancel">
|
||||
<i class="pi pi-times" />
|
||||
|
||||
511
src/components/dialog/content/setting/MembersPanelContent.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div
|
||||
class="flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke border-inter p-6"
|
||||
>
|
||||
<!-- Section Header -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span
|
||||
v-if="uiConfig.showMembersList"
|
||||
class="text-base font-semibold text-base-foreground"
|
||||
>
|
||||
<template v-if="activeView === 'active'">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count: members.length
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="permissions.canViewPendingInvites">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.pendingInvitesCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
size="lg"
|
||||
class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members Content -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<!-- Table Header with Tab Buttons and Column Headers -->
|
||||
<div
|
||||
v-if="uiConfig.showMembersList"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'active'"
|
||||
>
|
||||
{{ $t('workspacePanel.members.tabs.active') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="uiConfig.showPendingTab"
|
||||
:variant="
|
||||
activeView === 'pending' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'pending'"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.tabs.pendingCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Date column headers -->
|
||||
<template v-if="activeView === 'pending'">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('inviteDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.inviteDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('expiryDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.expiryDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<div />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Active Members -->
|
||||
<template v-if="activeView === 'active'">
|
||||
<!-- Personal Workspace: show only current user -->
|
||||
<template v-if="isPersonalWorkspace">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.membersGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
class="size-8"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{ 'text-xl!': !userPhotoUrl }"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ userDisplayName }}
|
||||
<span class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="uiConfig.showRoleBadge"
|
||||
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.roleOwner') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ userEmail }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Team Workspace: sorted list (owner first, current user second, then rest) -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(member, index) in filteredMembers"
|
||||
:key="member.id"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.membersGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
class="size-8"
|
||||
:photo-url="
|
||||
isCurrentUser(member) ? userPhotoUrl : undefined
|
||||
"
|
||||
:pt:icon:class="{
|
||||
'text-xl!': !isCurrentUser(member) || !userPhotoUrl
|
||||
}"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span
|
||||
v-if="isCurrentUser(member)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="uiConfig.showRoleBadge"
|
||||
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
|
||||
>
|
||||
{{ getRoleBadgeLabel(member.role) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Join date -->
|
||||
<span
|
||||
v-if="uiConfig.showDateColumn"
|
||||
class="text-sm text-muted-foreground text-right"
|
||||
>
|
||||
{{ formatDate(member.joinDate) }}
|
||||
</span>
|
||||
<!-- Remove member action (OWNER only, can't remove yourself) -->
|
||||
<div
|
||||
v-if="permissions.canRemoveMembers"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<Button
|
||||
v-if="!isCurrentUser(member)"
|
||||
v-tooltip="{
|
||||
value: $t('g.moreOptions'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="showMemberMenu($event, member)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member actions menu (shared for all members) -->
|
||||
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(invite, index) in filteredPendingInvites"
|
||||
:key="invite.id"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.pendingGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Invite info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ getInviteInitial(invite.email) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ getInviteDisplayName(invite.email) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ invite.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Invite date -->
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.inviteDate) }}
|
||||
</span>
|
||||
<!-- Expiry date -->
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.expiryDate) }}
|
||||
</span>
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('workspacePanel.members.actions.copyLink'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="$t('workspacePanel.members.actions.copyLink')"
|
||||
@click="handleCopyInviteLink(invite)"
|
||||
>
|
||||
<i class="icon-[lucide--link] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('workspacePanel.members.actions.revokeInvite'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="
|
||||
$t('workspacePanel.members.actions.revokeInvite')
|
||||
"
|
||||
@click="handleRevokeInvite(invite)"
|
||||
>
|
||||
<i class="icon-[lucide--mail-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredPendingInvites.length === 0"
|
||||
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.members.noInvites') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Personal Workspace Message -->
|
||||
<div v-if="isPersonalWorkspace" class="flex items-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
|
||||
</p>
|
||||
<button
|
||||
class="underline bg-transparent border-none cursor-pointer"
|
||||
@click="handleCreateWorkspace"
|
||||
>
|
||||
{{ $t('workspacePanel.members.createNewWorkspace') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type {
|
||||
PendingInvite,
|
||||
WorkspaceMember
|
||||
} from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { d, t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
|
||||
const {
|
||||
showRemoveMemberDialog,
|
||||
showRevokeInviteDialog,
|
||||
showCreateWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
members,
|
||||
pendingInvites,
|
||||
isInPersonalWorkspace: isPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { copyInviteLink } = workspaceStore
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeView = ref<'active' | 'pending'>('active')
|
||||
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
|
||||
const sortDirection = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
const selectedMember = ref<WorkspaceMember | null>(null)
|
||||
|
||||
function getInviteDisplayName(email: string): string {
|
||||
return email.split('@')[0]
|
||||
}
|
||||
|
||||
function getInviteInitial(email: string): string {
|
||||
return email.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
const memberMenuItems = computed(() => [
|
||||
{
|
||||
label: t('workspacePanel.members.actions.removeMember'),
|
||||
icon: 'pi pi-user-minus',
|
||||
command: () => {
|
||||
if (selectedMember.value) {
|
||||
handleRemoveMember(selectedMember.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
function showMemberMenu(event: Event, member: WorkspaceMember) {
|
||||
selectedMember.value = member
|
||||
memberMenu.value?.toggle(event)
|
||||
}
|
||||
|
||||
function isCurrentUser(member: WorkspaceMember): boolean {
|
||||
return member.email.toLowerCase() === userEmail.value?.toLowerCase()
|
||||
}
|
||||
|
||||
// All members sorted: owners first, current user second, then rest by join date
|
||||
const filteredMembers = computed(() => {
|
||||
let result = [...members.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(member) =>
|
||||
member.name.toLowerCase().includes(query) ||
|
||||
member.email.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
// Owners always come first
|
||||
if (a.role === 'owner' && b.role !== 'owner') return -1
|
||||
if (a.role !== 'owner' && b.role === 'owner') return 1
|
||||
|
||||
// Current user comes second (after owner)
|
||||
const aIsCurrentUser = isCurrentUser(a)
|
||||
const bIsCurrentUser = isCurrentUser(b)
|
||||
if (aIsCurrentUser && !bIsCurrentUser) return -1
|
||||
if (!aIsCurrentUser && bIsCurrentUser) return 1
|
||||
|
||||
// Then sort by join date
|
||||
const aValue = a.joinDate.getTime()
|
||||
const bValue = b.joinDate.getTime()
|
||||
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function getRoleBadgeLabel(role: 'owner' | 'member'): string {
|
||||
return role === 'owner'
|
||||
? t('workspaceSwitcher.roleOwner')
|
||||
: t('workspaceSwitcher.roleMember')
|
||||
}
|
||||
|
||||
const filteredPendingInvites = computed(() => {
|
||||
let result = [...pendingInvites.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter((invite) =>
|
||||
invite.email.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
const field = sortField.value === 'joinDate' ? 'inviteDate' : sortField.value
|
||||
result.sort((a, b) => {
|
||||
const aDate = a[field]
|
||||
const bDate = b[field]
|
||||
if (!aDate || !bDate) return 0
|
||||
const aValue = aDate.getTime()
|
||||
const bValue = bDate.getTime()
|
||||
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function toggleSort(field: 'inviteDate' | 'expiryDate' | 'joinDate') {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, { dateStyle: 'medium' })
|
||||
}
|
||||
|
||||
async function handleCopyInviteLink(invite: PendingInvite) {
|
||||
try {
|
||||
await copyInviteLink(invite.id)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.copied'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRevokeInvite(invite: PendingInvite) {
|
||||
showRevokeInviteDialog(invite.id)
|
||||
}
|
||||
|
||||
function handleCreateWorkspace() {
|
||||
showCreateWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleRemoveMember(member: WorkspaceMember) {
|
||||
showRemoveMemberDialog(member.id)
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
238
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<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 unstyled :value="activeTab" @update:value="setActiveTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabList unstyled class="flex w-full gap-2">
|
||||
<Tab
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'plan' && 'text-base-foreground no-underline'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</Tab>
|
||||
<Tab
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'members' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'members' && 'text-base-foreground no-underline',
|
||||
'ml-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.tabs.membersCount', {
|
||||
count: isInPersonalWorkspace ? 1 : members.length
|
||||
})
|
||||
}}
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isInviteLimitReached"
|
||||
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus ml-1 text-sm" />
|
||||
</Button>
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
class="ml-2"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
: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' : 'cursor-pointer'
|
||||
]"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
item
|
||||
})
|
||||
"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabPanels unstyled>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabPanel>
|
||||
<TabPanel value="members">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</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 MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buttonVariants } from '@/components/ui/button/button.variants'
|
||||
import SubscriptionPanelContentWorkspace 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'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showInviteMemberDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
members,
|
||||
isInviteLimitReached,
|
||||
isWorkspaceSubscribed,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
const { activeTab, setActiveTab, workspaceRole, 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 inviteTooltip = computed(() => {
|
||||
if (!isInviteLimitReached.value) return null
|
||||
return t('workspacePanel.inviteLimitReached')
|
||||
})
|
||||
|
||||
function handleInviteMember() {
|
||||
if (isInviteLimitReached.value) return
|
||||
showInviteMemberDialog()
|
||||
}
|
||||
|
||||
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)
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</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>
|
||||
@@ -0,0 +1,112 @@
|
||||
<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()
|
||||
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,182 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[512px] 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">
|
||||
{{
|
||||
step === 'email'
|
||||
? $t('workspacePanel.inviteMemberDialog.title')
|
||||
: $t('workspacePanel.inviteMemberDialog.linkStep.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: Email Step -->
|
||||
<template v-if="step === 'email'">
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.message') }}
|
||||
</p>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
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.inviteMemberDialog.placeholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Email Step -->
|
||||
<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="!isValidEmail"
|
||||
@click="onCreateLink"
|
||||
>
|
||||
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Body: Link Step -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
|
||||
</p>
|
||||
<p class="m-0 text-sm font-medium text-base-foreground">
|
||||
{{ email }}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="generatedLink"
|
||||
readonly
|
||||
class="w-full cursor-pointer rounded-lg border border-border-default bg-transparent px-3 py-2 pr-10 text-sm text-base-foreground focus:outline-none"
|
||||
@click="onSelectLink"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-4 top-2 cursor-pointer"
|
||||
@click="onCopyLink"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
>
|
||||
<g clip-path="url(#clip0_2127_14348)">
|
||||
<path
|
||||
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
|
||||
stroke="white"
|
||||
stroke-width="1.3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2127_14348">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Link Step -->
|
||||
<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" @click="onCopyLink">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.linkStep.copyLink') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</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 dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const email = ref('')
|
||||
const step = ref<'email' | 'link'>('email')
|
||||
const generatedLink = ref('')
|
||||
|
||||
const isValidEmail = computed(() => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email.value)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'invite-member' })
|
||||
}
|
||||
|
||||
async function onCreateLink() {
|
||||
if (!isValidEmail.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
generatedLink.value = await workspaceStore.createInviteLink(email.value)
|
||||
step.value = 'link'
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
|
||||
detail: error instanceof Error ? error.message : undefined,
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedLink.value)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectLink(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
input.select()
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,83 @@
|
||||
<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.removeMemberDialog.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.removeMemberDialog.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="onRemove">
|
||||
{{ $t('workspacePanel.removeMemberDialog.remove') }}
|
||||
</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 { memberId } = defineProps<{
|
||||
memberId: string
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'remove-member' })
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.removeMember(memberId)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.removeMemberDialog.success'),
|
||||
life: 2000
|
||||
})
|
||||
dialogStore.closeDialog({ key: 'remove-member' })
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.removeMemberDialog.error'),
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<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.revokeInviteDialog.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.revokeInviteDialog.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="onRevoke">
|
||||
{{ $t('workspacePanel.revokeInviteDialog.revoke') }}
|
||||
</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 { inviteId } = defineProps<{
|
||||
inviteId: string
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'revoke-invite' })
|
||||
}
|
||||
|
||||
async function onRevoke() {
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.revokeInvite(inviteId)
|
||||
dialogStore.closeDialog({ key: 'revoke-invite' })
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : undefined,
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -126,11 +126,13 @@ import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
||||
import { useCopy } from '@/composables/useCopy'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { mergeCustomNodesI18n, t } from '@/i18n'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -139,6 +141,7 @@ import { useWorkflowService } from '@/platform/workflow/core/services/workflowSe
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
@@ -394,6 +397,9 @@ const loadCustomNodesI18n = async () => {
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { flags } = useFeatureFlags()
|
||||
// Set up invite loader during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -459,6 +465,12 @@ onMounted(async () => {
|
||||
// Load template from URL if present
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
|
||||
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
await import('@/platform/updates/common/releaseStore')
|
||||
|
||||
@@ -1,54 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
isEmpty?: boolean
|
||||
const {
|
||||
disabled,
|
||||
label,
|
||||
enableEmptyState,
|
||||
tooltip,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const isCollapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const isExpanded = computed(() => !isCollapse.value && !disabled)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
if (!tooltip) return undefined
|
||||
return { value: tooltip, showDelay: 1000 }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col bg-interface-panel-surface">
|
||||
<div :class="cn('flex flex-col bg-comfy-menu-bg', className)">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
isEmpty
|
||||
? {
|
||||
value: $t('rightSidePanel.inputsNoneTooltip'),
|
||||
showDelay: 1_000
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
v-tooltip="tooltipConfig"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
|
||||
!isEmpty && 'cursor-pointer'
|
||||
!disabled && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
:disabled="isEmpty"
|
||||
:disabled="disabled"
|
||||
@click="isCollapse = !isCollapse"
|
||||
>
|
||||
<span class="text-sm font-semibold line-clamp-2">
|
||||
<slot name="label" />
|
||||
<span class="text-sm font-semibold line-clamp-2 flex-1">
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<i
|
||||
v-if="!isEmpty"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground group-hover:text-base-foreground group-focus:text-base-foreground icon-[lucide--chevron-up] size-4 transition-all',
|
||||
isCollapse && '-rotate-180'
|
||||
'text-muted-foreground group-hover:text-base-foreground group-has-[.subbutton:hover]:text-muted-foreground group-focus:text-base-foreground icon-[lucide--chevron-up] size-4 transition-all',
|
||||
isCollapse && '-rotate-180',
|
||||
disabled && 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isCollapse && !isEmpty" class="pb-4">
|
||||
<div v-if="isExpanded" class="pb-4">
|
||||
<slot />
|
||||
</div>
|
||||
<slot v-else-if="enableEmptyState && disabled" name="empty">
|
||||
<div>
|
||||
{{ $t('g.empty') }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -219,7 +219,7 @@ const extraMenuItems = computed(() => [
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('g.settings'),
|
||||
icon: 'mdi mdi-cog-outline',
|
||||
icon: 'icon-[lucide--settings]',
|
||||
command: () => {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_settings_menu_opened'
|
||||
@@ -230,7 +230,7 @@ const extraMenuItems = computed(() => [
|
||||
{
|
||||
key: 'manage-extensions',
|
||||
label: t('menu.manageExtensions'),
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
icon: 'icon-[lucide--puzzle]',
|
||||
command: showManageExtensions
|
||||
}
|
||||
])
|
||||
|
||||
41
src/components/sidebar/ModeToggle.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
function toggleLinearMode() {
|
||||
useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'button' }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-1 bg-secondary-background rounded-lg w-10">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('linearMode.linearMode'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
|
||||
@click="toggleLinearMode"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('linearMode.graphMode'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
|
||||
@click="toggleLinearMode"
|
||||
>
|
||||
<i class="icon-[comfy--workflow]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -44,6 +44,9 @@
|
||||
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
<ModeToggle
|
||||
v-if="menuItemStore.hasSeenLinear || flags.linearToggleEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HelpCenterPopups :is-small="isSmall" />
|
||||
@@ -57,14 +60,17 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
@@ -80,9 +86,11 @@ const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const sideToolbarRef = ref<HTMLElement>()
|
||||
const topToolbarRef = ref<HTMLElement>()
|
||||
const bottomToolbarRef = ref<HTMLElement>()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isSmall = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||
|
||||
@@ -139,7 +139,15 @@ import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, h, nextTick, onMounted, ref, render } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
render
|
||||
} from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
|
||||
@@ -171,6 +179,8 @@ import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
|
||||
|
||||
const instance = getCurrentInstance()!
|
||||
const appContext = instance.appContext
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
@@ -272,6 +282,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
draggable: node.leaf,
|
||||
renderDragPreview(container) {
|
||||
const vnode = h(NodePreview, { nodeDef: node.data })
|
||||
vnode.appContext = appContext
|
||||
render(vnode, container)
|
||||
return () => {
|
||||
render(null, container)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="$t('sideToolbar.workflows')"
|
||||
v-bind="$attrs"
|
||||
class="workflows-sidebar-tab"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
|
||||
@@ -22,7 +22,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, nextTick, ref, render, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
h,
|
||||
nextTick,
|
||||
ref,
|
||||
render,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FolderCustomizationDialog from '@/components/common/CustomizationDialog.vue'
|
||||
@@ -41,6 +49,8 @@ import type {
|
||||
TreeNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
|
||||
const instance = getCurrentInstance()!
|
||||
const appContext = instance.appContext
|
||||
const props = defineProps<{
|
||||
filteredNodeDefs: ComfyNodeDefImpl[]
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
@@ -154,6 +164,7 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
},
|
||||
renderDragPreview(container) {
|
||||
const vnode = h(NodePreview, { nodeDef: node.data })
|
||||
vnode.appContext = appContext
|
||||
render(vnode, container)
|
||||
return () => {
|
||||
render(null, container)
|
||||
|
||||
@@ -25,7 +25,24 @@ vi.mock('firebase/auth', () => ({
|
||||
}))
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia')
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: vi.fn((store) => store)
|
||||
}))
|
||||
|
||||
// Mock the useFeatureFlags composable
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => ({
|
||||
flags: { teamWorkspacesEnabled: false }
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useTeamWorkspaceStore
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: vi.fn(() => ({
|
||||
workspaceName: { value: '' },
|
||||
initState: { value: 'idle' }
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- A button that shows current authenticated user's avatar -->
|
||||
<!-- A button that shows workspace icon (Cloud) or user avatar -->
|
||||
<template>
|
||||
<div>
|
||||
<Button
|
||||
@@ -16,9 +16,24 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
|
||||
<Skeleton
|
||||
v-if="showWorkspaceSkeleton"
|
||||
shape="circle"
|
||||
width="32px"
|
||||
height="32px"
|
||||
/>
|
||||
<WorkspaceProfilePic
|
||||
v-else-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-4 px-1" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -27,38 +42,77 @@
|
||||
:show-arrow="false"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'rounded-lg'
|
||||
class: 'rounded-lg w-80'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<CurrentUserPopover @close="closePopover" />
|
||||
<!-- Workspace mode: workspace-aware popover (only when ready) -->
|
||||
<CurrentUserPopoverWorkspace
|
||||
v-if="teamWorkspacesEnabled && initState === 'ready'"
|
||||
@close="closePopover"
|
||||
/>
|
||||
<!-- Legacy mode: original popover -->
|
||||
<CurrentUserPopover
|
||||
v-else-if="!teamWorkspacesEnabled"
|
||||
@close="closePopover"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||
)
|
||||
|
||||
const { showArrow = true, compact = false } = defineProps<{
|
||||
showArrow?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
|
||||
|
||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const { workspaceName: teamWorkspaceName, initState } = storeToRefs(
|
||||
useTeamWorkspaceStore()
|
||||
)
|
||||
|
||||
const showWorkspaceSkeleton = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'loading'
|
||||
)
|
||||
const showWorkspaceIcon = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'ready'
|
||||
)
|
||||
|
||||
const workspaceName = computed(() => {
|
||||
if (!showWorkspaceIcon.value) return ''
|
||||
return teamWorkspaceName.value
|
||||
})
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const closePopover = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
328
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<!-- 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>
|
||||
<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 -->
|
||||
<SubscribeButton
|
||||
v-else-if="isPersonalWorkspace"
|
||||
:fluid="false"
|
||||
:label="$t('workspaceSwitcher.subscribe')"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
<!-- Non-personal workspace: Navigate to workspace settings -->
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.subscribe') }}
|
||||
</Button>
|
||||
</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 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 {
|
||||
initState,
|
||||
workspaceName,
|
||||
isInPersonalWorkspace: isPersonalWorkspace,
|
||||
isWorkspaceSubscribed
|
||||
} = 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, subscriptionStatus } = useSubscription()
|
||||
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
const displayedCredits = computed(() => {
|
||||
if (initState.value !== 'ready') return ''
|
||||
// Only personal workspaces have subscription status from useSubscription()
|
||||
// Team workspaces don't have backend subscription data yet
|
||||
if (isPersonalWorkspace.value) {
|
||||
// Wait for subscription status to load
|
||||
if (subscriptionStatus.value === null) return ''
|
||||
return isActiveSubscription.value ? totalCredits.value : '0'
|
||||
}
|
||||
return '0'
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -4,7 +4,7 @@
|
||||
variant="textonly"
|
||||
@click="toggleHelpCenter"
|
||||
>
|
||||
{{ $t('menu.helpAndFeedback') }}
|
||||
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
|
||||
<i class="icon-[lucide--circle-help] ml-0.5" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
|
||||
@@ -7,15 +7,10 @@
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Button
|
||||
v-if="isActiveTab"
|
||||
class="context-menu-button -mx-1 w-auto px-1 py-0"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
@click.stop="handleMenuClick"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
</Button>
|
||||
<i
|
||||
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
@@ -43,26 +38,9 @@
|
||||
:thumbnail-url="thumbnailUrl"
|
||||
:is-active-tab="isActiveTab"
|
||||
/>
|
||||
|
||||
<Menu
|
||||
v-if="isActiveTab"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
:pt="{
|
||||
root: {
|
||||
style: 'background-color: var(--comfy-menu-bg)'
|
||||
},
|
||||
itemLink: {
|
||||
class: 'py-2'
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuState } from 'primevue/menu'
|
||||
import Menu from 'primevue/menu'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -71,14 +49,11 @@ import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
@@ -143,12 +118,6 @@ const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(() =>
|
||||
useCommandStore().execute('Comfy.RenameWorkflow')
|
||||
)
|
||||
|
||||
// Event handlers that delegate to the popover component
|
||||
const handleMouseEnter = (event: Event) => {
|
||||
popoverRef.value?.showPopover(event)
|
||||
@@ -162,14 +131,6 @@ const handleClick = (event: Event) => {
|
||||
popoverRef.value?.togglePopover(event)
|
||||
}
|
||||
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'workflow_tab_menu_selected'
|
||||
})
|
||||
// Show breadcrumb menu instead of emitting context click
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
for (const opt of options) {
|
||||
if (
|
||||
|
||||
196
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<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">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{
|
||||
workspace.type === 'personal'
|
||||
? $t('workspaceSwitcher.personal')
|
||||
: workspace.name
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="getTierLabel(workspace)"
|
||||
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
|
||||
>
|
||||
{{ getTierLabel(workspace) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs 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>
|
||||
|
||||
<!-- 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 { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
WorkspaceRole,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
|
||||
|
||||
interface AvailableWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
role: WorkspaceRole
|
||||
isSubscribed: boolean
|
||||
subscriptionPlan: SubscriptionPlan
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [workspace: AvailableWorkspace]
|
||||
create: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
|
||||
|
||||
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,
|
||||
isSubscribed: w.isSubscribed,
|
||||
subscriptionPlan: w.subscriptionPlan
|
||||
}))
|
||||
)
|
||||
|
||||
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 ''
|
||||
}
|
||||
|
||||
function getTierLabel(workspace: AvailableWorkspace): string | null {
|
||||
// Personal workspace: use user's subscription tier
|
||||
if (workspace.type === 'personal') {
|
||||
return userSubscriptionTierName.value || null
|
||||
}
|
||||
// Team workspace: use workspace subscription plan
|
||||
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
|
||||
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
|
||||
return t('subscription.tiers.pro.name')
|
||||
if (workspace.subscriptionPlan === 'PRO_YEARLY')
|
||||
return t('subscription.tierNameYearly', {
|
||||
name: t('subscription.tiers.pro.name')
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
const success = await switchWithConfirmation(workspace.id)
|
||||
if (success) {
|
||||
emit('select', workspace)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateWorkspace() {
|
||||
emit('create')
|
||||
}
|
||||
</script>
|
||||
19
src/components/ui/AGENTS.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# UI Component Guidelines
|
||||
|
||||
## Adding New Components
|
||||
|
||||
```bash
|
||||
pnpm dlx shadcn-vue@latest add <component-name> --yes
|
||||
```
|
||||
|
||||
After adding, create `ComponentName.stories.ts` with Default, Disabled, and variant stories.
|
||||
|
||||
## Reka UI Wrapper Components
|
||||
|
||||
- Use reactive props destructuring with rest: `const { class: className, ...restProps } = defineProps<Props>()`
|
||||
- Use `useForwardProps(restProps)` for prop forwarding, or `computed()` if adding defaults
|
||||
- Import siblings directly (`./Component.vue`), not from barrel (`'.'`)
|
||||
- Use `cn()` for class merging with `className`
|
||||
- Use Iconify icons: `<i class="icon-[lucide--check]" />`
|
||||
- Use design tokens: `bg-secondary-background`, `text-muted-foreground`, `border-border-default`
|
||||
- Tailwind 4 CSS variables use parentheses: `h-(--my-var)` not `h-[--my-var]`
|
||||
76
src/components/ui/Popover.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
entries?: { label: string; action?: () => void; icon?: string }[][]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-slot="{ close }">
|
||||
<PopoverTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button size="icon">
|
||||
<i :class="icon ?? 'icon-[lucide--ellipsis]'" />
|
||||
</Button>
|
||||
</slot>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal :to>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||
>
|
||||
<slot>
|
||||
<div class="flex flex-col p-1">
|
||||
<section
|
||||
v-for="(entryGroup, index) in entries ?? []"
|
||||
:key="index"
|
||||
class="flex flex-col border-b-2 last:border-none border-border-subtle"
|
||||
>
|
||||
<div
|
||||
v-for="{ label, action, icon } in entryGroup"
|
||||
:key="label"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-row gap-4 p-2 rounded-sm my-1',
|
||||
action &&
|
||||
'cursor-pointer hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="
|
||||
() => {
|
||||
if (!action) return
|
||||
action()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i v-if="icon" :class="icon" />
|
||||
{{ label }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</slot>
|
||||
<PopoverArrow class="fill-base-background stroke-border-subtle" />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
29
src/components/ui/TypeformPopoverButton.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
dataTfWidget: string
|
||||
}>()
|
||||
|
||||
const feedbackRef = useTemplateRef('feedbackRef')
|
||||
|
||||
whenever(feedbackRef, () => {
|
||||
const scriptEl = document.createElement('script')
|
||||
scriptEl.src = '//embed.typeform.com/next/embed.js'
|
||||
feedbackRef.value?.appendChild(scriptEl)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button variant="inverted" class="rounded-full size-12">
|
||||
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||
</Button>
|
||||
</template>
|
||||
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
</Popover>
|
||||
</template>
|
||||
59
src/components/ui/ZoomPane.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
const zoomPane = useTemplateRef('zoomPane')
|
||||
|
||||
const zoom = ref(1.0)
|
||||
const panX = ref(0.0)
|
||||
const panY = ref(0.0)
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const zoomPaneEl = zoomPane.value
|
||||
if (!zoomPaneEl) return
|
||||
|
||||
zoom.value -= e.deltaY
|
||||
const { x, y, width, height } = zoomPaneEl.getBoundingClientRect()
|
||||
const offsetX = e.clientX - x - width / 2
|
||||
const offsetY = e.clientY - y - height / 2
|
||||
const scaler = 1.1 ** (e.deltaY / -30)
|
||||
|
||||
panY.value = panY.value * scaler - offsetY * (scaler - 1)
|
||||
panX.value = panX.value * scaler - offsetX * (scaler - 1)
|
||||
}
|
||||
|
||||
let dragging = false
|
||||
function handleDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
|
||||
const zoomPaneEl = zoomPane.value
|
||||
if (!zoomPaneEl) return
|
||||
zoomPaneEl.parentElement?.focus()
|
||||
|
||||
zoomPaneEl.setPointerCapture(e.pointerId)
|
||||
dragging = true
|
||||
}
|
||||
function handleMove(e: PointerEvent) {
|
||||
if (!dragging) return
|
||||
panX.value += e.movementX
|
||||
panY.value += e.movementY
|
||||
}
|
||||
|
||||
const transform = computed(() => {
|
||||
const scale = 1.1 ** (zoom.value / 30)
|
||||
const matrix = [scale, 0, 0, scale, panX.value, panY.value]
|
||||
return `matrix(${matrix.join(',')})`
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
ref="zoomPane"
|
||||
class="contain-size place-content-center"
|
||||
@wheel="handleWheel"
|
||||
@pointerdown.prevent="handleDown"
|
||||
@pointermove="handleMove"
|
||||
@pointerup="dragging = false"
|
||||
@pointercancel="dragging = false"
|
||||
>
|
||||
<slot :style="{ transform }" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,7 +26,8 @@ export const buttonVariants = cva({
|
||||
md: 'h-8 rounded-lg p-2 text-xs',
|
||||
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
|
||||
icon: 'size-8',
|
||||
'icon-sm': 'size-5 p-0'
|
||||
'icon-sm': 'size-5 p-0',
|
||||
unset: ''
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
261
src/components/ui/select/Select.stories.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Select from './Select.vue'
|
||||
import SelectContent from './SelectContent.vue'
|
||||
import SelectGroup from './SelectGroup.vue'
|
||||
import SelectItem from './SelectItem.vue'
|
||||
import SelectLabel from './SelectLabel.vue'
|
||||
import SelectSeparator from './SelectSeparator.vue'
|
||||
import SelectTrigger from './SelectTrigger.vue'
|
||||
import SelectValue from './SelectValue.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Select',
|
||||
component: Select,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text',
|
||||
description: 'Selected value'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'When true, disables the select'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' }
|
||||
}
|
||||
} satisfies Meta<typeof Select>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref(args.modelValue || '')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="cherry">Cherry</SelectItem>
|
||||
<SelectItem value="grape">Grape</SelectItem>
|
||||
<SelectItem value="orange">Orange</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Selected: {{ value || 'None' }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Choose an option..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('apple')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" disabled>
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="cherry">Cherry</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithGroups: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a model type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Checkpoints</SelectLabel>
|
||||
<SelectItem value="sd15">SD 1.5</SelectItem>
|
||||
<SelectItem value="sdxl">SDXL</SelectItem>
|
||||
<SelectItem value="flux">Flux</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>LoRAs</SelectLabel>
|
||||
<SelectItem value="lora-style">Style LoRA</SelectItem>
|
||||
<SelectItem value="lora-character">Character LoRA</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Other</SelectLabel>
|
||||
<SelectItem value="vae">VAE</SelectItem>
|
||||
<SelectItem value="embedding">Embedding</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Selected: {{ value || 'None' }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Scrollable: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
const items = Array.from({ length: 20 }, (_, i) => ({
|
||||
value: `item-${i + 1}`,
|
||||
label: `Option ${i + 1}`
|
||||
}))
|
||||
return { value, items, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomWidth: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-32">
|
||||
<SelectValue placeholder="Small" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="a">A</SelectItem>
|
||||
<SelectItem value="b">B</SelectItem>
|
||||
<SelectItem value="c">C</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Full width select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
16
src/components/ui/select/Select.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
73
src/components/ui/select/SelectContent.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits
|
||||
} from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import SelectScrollDownButton from './SelectScrollDownButton.vue'
|
||||
import SelectScrollUpButton from './SelectScrollUpButton.vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
position = 'popper',
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
position,
|
||||
...restProps
|
||||
}))
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'relative z-3000 max-h-96 min-w-32 overflow-hidden',
|
||||
'mt-2 rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default',
|
||||
'shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:class="
|
||||
cn(
|
||||
'scrollbar-custom flex flex-col gap-0',
|
||||
position === 'popper' &&
|
||||
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</template>
|
||||
17
src/components/ui/select/SelectGroup.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from 'reka-ui'
|
||||
import { SelectGroup } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectGroupProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
37
src/components/ui/select/SelectItem.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItemProps } from 'reka-ui'
|
||||
import { SelectItem, SelectItemIndicator, SelectItemText } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectItemProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center justify-between',
|
||||
'gap-3 rounded px-2 py-3 text-sm outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'focus:bg-secondary-background-hover',
|
||||
'data-[state=checked]:bg-secondary-background-selected',
|
||||
'data-[state=checked]:hover:bg-secondary-background-selected',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectItemText class="truncate">
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
|
||||
<SelectItemIndicator class="flex shrink-0 items-center justify-center">
|
||||
<i class="icon-[lucide--check] text-base-foreground" aria-hidden="true" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</template>
|
||||
25
src/components/ui/select/SelectLabel.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectLabelProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
27
src/components/ui/select/SelectScrollDownButton.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||
import { SelectScrollDownButton } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</template>
|
||||
27
src/components/ui/select/SelectScrollUpButton.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||
import { SelectScrollUpButton } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<i class="icon-[lucide--chevron-up]" />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</template>
|
||||
18
src/components/ui/select/SelectSeparator.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from 'reka-ui'
|
||||
import { SelectSeparator } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator
|
||||
v-bind="restProps"
|
||||
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
|
||||
/>
|
||||
</template>
|
||||
36
src/components/ui/select/SelectTrigger.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from 'reka-ui'
|
||||
import { SelectIcon, SelectTrigger } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full cursor-pointer select-none items-center justify-between',
|
||||
'rounded-lg px-4 py-2 text-sm',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus:border-node-component-border focus:outline-none',
|
||||
'data-[placeholder]:text-muted-foreground',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
'[&>span]:truncate',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<i class="icon-[lucide--chevron-down] shrink-0 text-muted-foreground" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
12
src/components/ui/select/SelectValue.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectValueProps } from 'reka-ui'
|
||||
import { SelectValue } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue v-bind="props">
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
||||
182
src/components/ui/tags-input/TagsInput.stories.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TagsInput from './TagsInput.vue'
|
||||
import TagsInputInput from './TagsInputInput.vue'
|
||||
import TagsInputItem from './TagsInputItem.vue'
|
||||
import TagsInputItemDelete from './TagsInputItemDelete.vue'
|
||||
import TagsInputItemText from './TagsInputItemText.vue'
|
||||
|
||||
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
|
||||
component: ComponentExposed<C>
|
||||
}
|
||||
|
||||
const meta: GenericMeta<typeof TagsInput> = {
|
||||
title: 'Components/TagsInput',
|
||||
component: TagsInput,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'object',
|
||||
description: 'Array of tag values'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description:
|
||||
'When true, completely disables the component. When false (default), shows read-only state with edit icon until clicked.'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref(args.modelValue || ['tag1', 'tag2'])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
|
||||
</TagsInput>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Tags: {{ tags.join(', ') }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: ['Vue', 'TypeScript'],
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
disabled: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref<string[]>([])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Start typing to add tags..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ManyTags: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref([
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Vue',
|
||||
'React',
|
||||
'Svelte',
|
||||
'Node.js',
|
||||
'Python',
|
||||
'Rust'
|
||||
])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-96" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Add technology..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true
|
||||
},
|
||||
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref(['Read', 'Only', 'Tags'])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Cannot add tags..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const CustomWidth: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref(['Full', 'Width'])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-full" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
161
src/components/ui/tags-input/TagsInput.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { h, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TagsInput from './TagsInput.vue'
|
||||
import TagsInputInput from './TagsInputInput.vue'
|
||||
import TagsInputItem from './TagsInputItem.vue'
|
||||
import TagsInputItemDelete from './TagsInputItemDelete.vue'
|
||||
import TagsInputItemText from './TagsInputItemText.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { removeTag: 'Remove tag' } } }
|
||||
})
|
||||
|
||||
describe('TagsInput', () => {
|
||||
function mountTagsInput(props = {}, slots = {}) {
|
||||
return mount(TagsInput, {
|
||||
props: {
|
||||
modelValue: [],
|
||||
...props
|
||||
},
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mountTagsInput({}, { default: '<span>Slot Content</span>' })
|
||||
|
||||
expect(wrapper.text()).toContain('Slot Content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TagsInput with child components', () => {
|
||||
function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) {
|
||||
return mount(TagsInput, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
modelValue: tags
|
||||
},
|
||||
slots: {
|
||||
default: () => [
|
||||
...tags.map((tag) =>
|
||||
h(TagsInputItem, { key: tag, value: tag }, () => [
|
||||
h(TagsInputItemText),
|
||||
h(TagsInputItemDelete)
|
||||
])
|
||||
),
|
||||
h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders tags structure and content', () => {
|
||||
const tags = ['tag1', 'tag2']
|
||||
const wrapper = mountFullTagsInput(tags)
|
||||
|
||||
const items = wrapper.findAllComponents(TagsInputItem)
|
||||
const textElements = wrapper.findAllComponents(TagsInputItemText)
|
||||
const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete)
|
||||
|
||||
expect(items).toHaveLength(tags.length)
|
||||
expect(textElements).toHaveLength(tags.length)
|
||||
expect(deleteButtons).toHaveLength(tags.length)
|
||||
|
||||
textElements.forEach((el, i) => {
|
||||
expect(el.text()).toBe(tags[i])
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates model value when adding a tag', async () => {
|
||||
let currentTags = ['existing']
|
||||
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: currentTags,
|
||||
'onUpdate:modelValue': (payload) => {
|
||||
currentTags = payload
|
||||
}
|
||||
},
|
||||
slots: {
|
||||
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
await input.setValue('newTag')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await nextTick()
|
||||
|
||||
expect(currentTags).toContain('newTag')
|
||||
})
|
||||
|
||||
it('does not enter edit mode when disabled', async () => {
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: ['tag1'],
|
||||
disabled: true
|
||||
},
|
||||
slots: {
|
||||
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('exits edit mode when clicking outside', async () => {
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: ['tag1']
|
||||
},
|
||||
slots: {
|
||||
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.find('input').exists()).toBe(true)
|
||||
|
||||
document.body.click()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows placeholder when modelValue is empty', async () => {
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: []
|
||||
},
|
||||
slots: {
|
||||
default: ({ isEmpty }: { isEmpty: boolean }) =>
|
||||
h(TagsInputInput, { placeholder: 'Add tag...', isEmpty })
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.attributes('placeholder')).toBe('Add tag...')
|
||||
})
|
||||
})
|
||||
77
src/components/ui/tags-input/TagsInput.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts" generic="T extends AcceptableInputValue = string">
|
||||
import { onClickOutside, useCurrentElement } from '@vueuse/core'
|
||||
import type {
|
||||
AcceptableInputValue,
|
||||
TagsInputRootEmits,
|
||||
TagsInputRootProps
|
||||
} from 'reka-ui'
|
||||
import { TagsInputRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { computed, nextTick, provide, ref } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
|
||||
import type { FocusCallback } from './tagsInputContext'
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<TagsInputRootEmits<T>>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const rootEl = useCurrentElement<HTMLElement>()
|
||||
const focusInput = ref<FocusCallback>()
|
||||
|
||||
provide(tagsInputFocusKey, (callback: FocusCallback) => {
|
||||
focusInput.value = callback
|
||||
})
|
||||
provide(tagsInputIsEditingKey, isEditing)
|
||||
|
||||
const internalDisabled = computed(() => disabled || !isEditing.value)
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
...restProps,
|
||||
disabled: internalDisabled.value
|
||||
}))
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
|
||||
async function enableEditing() {
|
||||
if (!disabled && !isEditing.value) {
|
||||
isEditing.value = true
|
||||
await nextTick()
|
||||
focusInput.value?.()
|
||||
}
|
||||
}
|
||||
|
||||
onClickOutside(rootEl, () => {
|
||||
isEditing.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputRoot
|
||||
v-slot="{ modelValue }"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'group relative flex flex-wrap items-center gap-2 rounded-lg bg-transparent p-2 text-xs text-base-foreground',
|
||||
!internalDisabled &&
|
||||
'hover:bg-modal-card-background-hovered focus-within:bg-modal-card-background-hovered',
|
||||
!disabled && !isEditing && 'cursor-pointer',
|
||||
className
|
||||
)
|
||||
"
|
||||
@click="enableEditing"
|
||||
>
|
||||
<slot :is-empty="modelValue.length === 0" />
|
||||
<i
|
||||
v-if="!disabled && !isEditing"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
|
||||
/>
|
||||
</TagsInputRoot>
|
||||
</template>
|
||||
54
src/components/ui/tags-input/TagsInputInput.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputInputProps } from 'reka-ui'
|
||||
import { TagsInputInput, useForwardExpose, useForwardProps } from 'reka-ui'
|
||||
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
|
||||
|
||||
const {
|
||||
isEmpty = false,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
TagsInputInputProps & { class?: HTMLAttributes['class']; isEmpty?: boolean }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
const isEditing = inject(tagsInputIsEditingKey, ref(true))
|
||||
const showInput = computed(() => isEditing.value || isEmpty)
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose()
|
||||
const registerFocus = inject(tagsInputFocusKey, undefined)
|
||||
|
||||
function handleEscape() {
|
||||
currentElement.value?.blur()
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
registerFocus?.(() => currentElement.value?.focus())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
registerFocus?.(undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputInput
|
||||
v-if="showInput"
|
||||
:ref="forwardRef"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'min-h-6 flex-1 bg-transparent text-xs text-muted-foreground placeholder:text-muted-foreground focus:outline-none appearance-none border-none',
|
||||
!isEditing && 'pointer-events-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
@keydown.escape.stop="handleEscape"
|
||||
/>
|
||||
</template>
|
||||
27
src/components/ui/tags-input/TagsInputItem.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputItemProps } from 'reka-ui'
|
||||
import { TagsInputItem, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
TagsInputItemProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-6 items-center gap-1 rounded-sm bg-modal-card-tag-background py-1 pl-2 pr-1 text-modal-card-tag-foreground backdrop-blur-sm ring-offset-base-background data-[state=active]:ring-2 data-[state=active]:ring-base-foreground data-[state=active]:ring-offset-1',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TagsInputItem>
|
||||
</template>
|
||||
36
src/components/ui/tags-input/TagsInputItemDelete.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputItemDeleteProps } from 'reka-ui'
|
||||
import { TagsInputItemDelete, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItemDelete
|
||||
v-bind="forwardedProps"
|
||||
:as="Button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.removeTag')"
|
||||
:class="
|
||||
cn(
|
||||
'opacity-60 hover:bg-transparent hover:opacity-100 transition-[opacity,width] duration-150 w-4 data-[disabled]:w-0 data-[disabled]:opacity-0 data-[disabled]:pointer-events-none overflow-hidden',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</slot>
|
||||
</TagsInputItemDelete>
|
||||
</template>
|
||||
20
src/components/ui/tags-input/TagsInputItemText.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputItemTextProps } from 'reka-ui'
|
||||
import { TagsInputItemText, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
TagsInputItemTextProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItemText
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('bg-transparent text-xs', className)"
|
||||
/>
|
||||
</template>
|
||||
10
src/components/ui/tags-input/tagsInputContext.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
export type FocusCallback = (() => void) | undefined
|
||||
|
||||
export const tagsInputFocusKey: InjectionKey<
|
||||
(callback: FocusCallback) => void
|
||||
> = Symbol('tagsInputFocus')
|
||||
|
||||
export const tagsInputIsEditingKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('tagsInputIsEditing')
|
||||
@@ -117,7 +117,7 @@
|
||||
</template>
|
||||
|
||||
<template #rightPanel>
|
||||
<RightSidePanel></RightSidePanel>
|
||||
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
@@ -136,7 +136,6 @@ import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
@@ -15,7 +15,6 @@ import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
import LeftSidePanel from '../panel/LeftSidePanel.vue'
|
||||
import RightSidePanel from '../panel/RightSidePanel.vue'
|
||||
import BaseModalLayout from './BaseModalLayout.vue'
|
||||
|
||||
interface StoryArgs {
|
||||
@@ -69,7 +68,6 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
components: {
|
||||
BaseModalLayout,
|
||||
LeftSidePanel,
|
||||
RightSidePanel,
|
||||
SearchBox,
|
||||
MultiSelect,
|
||||
SingleSelect,
|
||||
@@ -175,16 +173,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
template: `
|
||||
<div>
|
||||
<BaseModalLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
|
||||
<!-- Left Panel Header Title -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
|
||||
<!-- Left Panel -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
|
||||
</template>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -299,16 +296,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<BaseModalLayout v-else :content-title="args.contentTitle || 'Content Title'">
|
||||
<!-- Same content but WITH right panel -->
|
||||
<!-- Left Panel Header Title -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
|
||||
<!-- Left Panel -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
|
||||
</template>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -415,7 +411,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Right Panel - Only when hasRightPanel is true -->
|
||||
<template #rightPanel>
|
||||
<RightSidePanel />
|
||||
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</div>
|
||||
|
||||
@@ -1,100 +1,135 @@
|
||||
<template>
|
||||
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
|
||||
<Button
|
||||
v-show="!isRightPanelOpen && hasRightPanel"
|
||||
size="icon"
|
||||
:class="
|
||||
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
|
||||
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
||||
})
|
||||
"
|
||||
@click="toggleRightPanel"
|
||||
<div
|
||||
class="base-widget-layout rounded-2xl overflow-hidden relative"
|
||||
@keydown.esc.capture="handleEscape"
|
||||
>
|
||||
<div
|
||||
class="grid h-full w-full transition-[grid-template-columns] duration-300 ease-out"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] text-sm" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
<div class="flex h-full w-full">
|
||||
<Transition name="slide-panel">
|
||||
<nav
|
||||
v-if="$slots.leftPanel && showLeftPanel"
|
||||
:class="[
|
||||
PANEL_SIZES.width,
|
||||
PANEL_SIZES.minWidth,
|
||||
PANEL_SIZES.maxWidth
|
||||
]"
|
||||
<nav
|
||||
class="h-full overflow-hidden bg-modal-panel-background flex flex-col"
|
||||
:inert="!showLeftPanel"
|
||||
:aria-hidden="!showLeftPanel"
|
||||
>
|
||||
<header
|
||||
data-component-id="LeftPanelHeader"
|
||||
class="flex w-full h-18 shrink-0 gap-2 pl-6 pr-3 items-center-safe"
|
||||
>
|
||||
<slot name="leftPanel"></slot>
|
||||
</nav>
|
||||
</Transition>
|
||||
|
||||
<div class="flex-1 flex bg-base-background">
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="w-full h-18 px-6 flex items-center justify-between gap-2"
|
||||
<slot name="leftPanelHeaderTitle" />
|
||||
<Button
|
||||
v-if="!notMobile && showLeftPanel"
|
||||
size="lg"
|
||||
class="w-10 p-0 ml-auto"
|
||||
:aria-label="t('g.hideLeftPanel')"
|
||||
@click="toggleLeftPanel"
|
||||
>
|
||||
<div class="flex flex-1 shrink-0 gap-2">
|
||||
<Button v-if="!notMobile" size="icon" @click="toggleLeftPanel">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
showLeftPanel
|
||||
? 'icon-[lucide--panel-left]'
|
||||
: 'icon-[lucide--panel-left-close]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<slot name="header-right-area"></slot>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-end gap-2 w-0',
|
||||
hasRightPanel && !isRightPanelOpen ? 'min-w-22' : 'min-w-10'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="isRightPanelOpen && hasRightPanel"
|
||||
size="icon"
|
||||
@click="toggleRightPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right-close]" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<i class="icon-[lucide--panel-left-close]" />
|
||||
</Button>
|
||||
</header>
|
||||
<slot name="leftPanel" />
|
||||
</nav>
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col">
|
||||
<!-- Fallback title bar when no leftPanel is provided -->
|
||||
<slot name="contentFilter"></slot>
|
||||
<h2
|
||||
v-if="!$slots.leftPanel"
|
||||
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
|
||||
>
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
<div
|
||||
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<aside
|
||||
v-if="hasRightPanel && isRightPanelOpen"
|
||||
class="w-1/4 min-w-40 max-w-80"
|
||||
<div class="flex flex-col bg-base-background overflow-hidden">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="w-full h-18 px-6 flex items-center justify-between gap-2"
|
||||
>
|
||||
<slot name="rightPanel"></slot>
|
||||
</aside>
|
||||
<div class="flex flex-1 shrink-0 gap-2">
|
||||
<Button
|
||||
v-if="!notMobile && !showLeftPanel"
|
||||
size="lg"
|
||||
class="w-10 p-0"
|
||||
:aria-label="t('g.showLeftPanel')"
|
||||
@click="toggleLeftPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-left]" />
|
||||
</Button>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<slot name="header-right-area" />
|
||||
<template v-if="!isRightPanelOpen">
|
||||
<Button
|
||||
v-if="hasRightPanel"
|
||||
size="lg"
|
||||
class="w-10 p-0"
|
||||
:aria-label="t('g.showRightPanel')"
|
||||
@click="toggleRightPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-10"
|
||||
:aria-label="t('g.closeDialog')"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col">
|
||||
<slot name="contentFilter" />
|
||||
<h2
|
||||
v-if="!hasLeftPanel"
|
||||
class="text-xxl m-0 select-none px-6 pt-2 pb-6 capitalize"
|
||||
>
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
<div
|
||||
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
v-if="hasRightPanel"
|
||||
class="overflow-hidden"
|
||||
:inert="!isRightPanelOpen"
|
||||
:aria-hidden="!isRightPanelOpen"
|
||||
>
|
||||
<div
|
||||
class="min-w-72 w-72 flex flex-col bg-modal-panel-background h-full"
|
||||
>
|
||||
<header
|
||||
data-component-id="RightPanelHeader"
|
||||
class="flex h-18 shrink-0 items-center gap-2 px-6"
|
||||
>
|
||||
<h2
|
||||
v-if="rightPanelTitle"
|
||||
class="flex-1 select-none text-base font-semibold"
|
||||
>
|
||||
{{ rightPanelTitle }}
|
||||
</h2>
|
||||
<div v-else class="flex-1">
|
||||
<slot name="rightPanelHeaderTitle" />
|
||||
</div>
|
||||
<slot name="rightPanelHeaderActions" />
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-10 p-0"
|
||||
:aria-label="t('g.hideRightPanel')"
|
||||
@click="toggleRightPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right-close] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-10 p-0"
|
||||
:aria-label="t('g.closeDialog')"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
</header>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<slot name="rightPanel" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -102,34 +137,36 @@
|
||||
<script setup lang="ts">
|
||||
import { useBreakpoints } from '@vueuse/core'
|
||||
import { computed, inject, ref, useSlots, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { contentTitle } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const { contentTitle, rightPanelTitle } = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
}>()
|
||||
|
||||
const BREAKPOINTS = { md: 880 }
|
||||
const PANEL_SIZES = {
|
||||
width: 'w-1/3',
|
||||
minWidth: 'min-w-40',
|
||||
maxWidth: 'max-w-56'
|
||||
}
|
||||
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||
default: false
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const hasLeftPanel = computed(() => !!slots.leftPanel)
|
||||
const hasRightPanel = computed(() => !!slots.rightPanel)
|
||||
|
||||
const BREAKPOINTS = { md: 880 }
|
||||
|
||||
const closeDialog = inject(OnCloseKey, () => {})
|
||||
|
||||
const breakpoints = useBreakpoints(BREAKPOINTS)
|
||||
const notMobile = breakpoints.greater('md')
|
||||
|
||||
const isLeftPanelOpen = ref<boolean>(true)
|
||||
const isRightPanelOpen = ref<boolean>(false)
|
||||
const mobileMenuOpen = ref<boolean>(false)
|
||||
|
||||
const hasRightPanel = computed(() => !!slots.rightPanel)
|
||||
|
||||
watch(notMobile, (isDesktop) => {
|
||||
if (!isDesktop) {
|
||||
mobileMenuOpen.value = false
|
||||
@@ -143,6 +180,12 @@ const showLeftPanel = computed(() => {
|
||||
return shouldShow
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: hasRightPanel.value
|
||||
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
|
||||
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
|
||||
}))
|
||||
|
||||
const toggleLeftPanel = () => {
|
||||
if (notMobile.value) {
|
||||
isLeftPanelOpen.value = !isLeftPanelOpen.value
|
||||
@@ -154,6 +197,23 @@ const toggleLeftPanel = () => {
|
||||
const toggleRightPanel = () => {
|
||||
isRightPanelOpen.value = !isRightPanelOpen.value
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (isRightPanelOpen.value) {
|
||||
event.stopPropagation()
|
||||
isRightPanelOpen.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.base-widget-layout {
|
||||
@@ -168,28 +228,4 @@ const toggleRightPanel = () => {
|
||||
max-width: 1724px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade transition for buttons */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Slide transition for left panel */
|
||||
.slide-panel-enter-active,
|
||||
.slide-panel-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.slide-panel-enter-from,
|
||||
.slide-panel-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,58 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
v-tooltip.right="{
|
||||
value: tooltipText,
|
||||
disabled: !isOverflowing,
|
||||
pt: { text: { class: 'whitespace-nowrap' } }
|
||||
}"
|
||||
class="flex cursor-pointer select-none items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
:class="
|
||||
active
|
||||
? 'bg-interface-menu-component-surface-selected'
|
||||
: 'hover:bg-interface-menu-component-surface-hovered'
|
||||
"
|
||||
role="button"
|
||||
@mouseenter="checkOverflow"
|
||||
@click="onClick"
|
||||
>
|
||||
<div v-if="icon" class="pt-0.5">
|
||||
<NavIcon :icon="icon" />
|
||||
</div>
|
||||
<NavIcon v-if="icon" :icon="icon" />
|
||||
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
|
||||
<span class="flex items-center break-all">
|
||||
<slot></slot>
|
||||
<span ref="textRef" class="min-w-0 truncate">
|
||||
<slot />
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="badge !== undefined"
|
||||
:label="String(badge)"
|
||||
severity="contrast"
|
||||
variant="circle"
|
||||
class="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import type { NavItemData } from '@/types/navTypes'
|
||||
|
||||
import NavIcon from './NavIcon.vue'
|
||||
|
||||
const { icon, active, onClick } = defineProps<{
|
||||
const { icon, badge, active, onClick } = defineProps<{
|
||||
icon: NavItemData['icon']
|
||||
badge?: NavItemData['badge']
|
||||
active?: boolean
|
||||
onClick: () => void
|
||||
}>()
|
||||
|
||||
const textRef = ref<HTMLElement | null>(null)
|
||||
const isOverflowing = ref(false)
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (!textRef.value) return
|
||||
isOverflowing.value =
|
||||
textRef.value.scrollWidth > textRef.value.clientWidth + 1
|
||||
}
|
||||
|
||||
const tooltipText = computed(() => textRef.value?.textContent ?? '')
|
||||
</script>
|
||||
|
||||
@@ -7,20 +7,6 @@ const meta: Meta<typeof LeftSidePanel> = {
|
||||
title: 'Components/Widget/Panel/LeftSidePanel',
|
||||
component: LeftSidePanel,
|
||||
argTypes: {
|
||||
'header-icon': {
|
||||
table: {
|
||||
type: { summary: 'slot' },
|
||||
defaultValue: { summary: 'undefined' }
|
||||
},
|
||||
control: false
|
||||
},
|
||||
'header-title': {
|
||||
table: {
|
||||
type: { summary: 'slot' },
|
||||
defaultValue: { summary: 'undefined' }
|
||||
},
|
||||
control: false
|
||||
},
|
||||
'onUpdate:modelValue': {
|
||||
table: { disable: true }
|
||||
}
|
||||
@@ -59,14 +45,7 @@ export const Default: Story = {
|
||||
},
|
||||
template: `
|
||||
<div style="height: 500px; width: 256px;">
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Navigation</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -126,14 +105,7 @@ export const WithGroups: Story = {
|
||||
},
|
||||
template: `
|
||||
<div style="height: 500px; width: 256px;">
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Model Selector</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
|
||||
<div class="mt-4 p-2 text-sm">
|
||||
Selected: {{ selectedItem }}
|
||||
</div>
|
||||
@@ -176,14 +148,7 @@ export const DefaultIcons: Story = {
|
||||
},
|
||||
template: `
|
||||
<div style="height: 400px; width: 256px;">
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--folder] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Files</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -228,14 +193,7 @@ export const LongLabels: Story = {
|
||||
},
|
||||
template: `
|
||||
<div style="height: 500px; width: 256px;">
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--settings] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Settings</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
@@ -1,45 +1,41 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col bg-modal-panel-background">
|
||||
<PanelHeader>
|
||||
<template #icon>
|
||||
<slot name="header-icon"></slot>
|
||||
</template>
|
||||
<slot name="header-title"></slot>
|
||||
</PanelHeader>
|
||||
|
||||
<nav
|
||||
class="flex scrollbar-hide flex-1 flex-col gap-1 overflow-y-auto px-3 py-4"
|
||||
<div
|
||||
class="flex w-full flex-auto overflow-y-auto gap-1 min-h-0 flex-col bg-modal-panel-background scrollbar-hide px-3"
|
||||
>
|
||||
<template
|
||||
v-for="item in navItems"
|
||||
:key="'title' in item ? item.title : item.id"
|
||||
>
|
||||
<template v-for="(item, index) in navItems" :key="index">
|
||||
<div v-if="'items' in item" class="flex flex-col gap-2">
|
||||
<NavTitle
|
||||
v-model="collapsedGroups[item.title]"
|
||||
:title="item.title"
|
||||
:collapsible="item.collapsible"
|
||||
/>
|
||||
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
|
||||
<NavItem
|
||||
v-for="subItem in item.items"
|
||||
:key="subItem.id"
|
||||
:icon="subItem.icon"
|
||||
:active="activeItem === subItem.id"
|
||||
@click="activeItem = subItem.id"
|
||||
>
|
||||
{{ subItem.label }}
|
||||
</NavItem>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-if="'items' in item" class="flex flex-col gap-2">
|
||||
<NavTitle
|
||||
v-model="collapsedGroups[item.title]"
|
||||
:title="item.title"
|
||||
:collapsible="item.collapsible"
|
||||
/>
|
||||
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
|
||||
<NavItem
|
||||
:icon="item.icon"
|
||||
:active="activeItem === item.id"
|
||||
@click="activeItem = item.id"
|
||||
v-for="subItem in item.items"
|
||||
:key="subItem.id"
|
||||
:icon="subItem.icon"
|
||||
:badge="subItem.badge"
|
||||
:active="activeItem === subItem.id"
|
||||
@click="activeItem = subItem.id"
|
||||
>
|
||||
{{ item.label }}
|
||||
{{ subItem.label }}
|
||||
</NavItem>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<NavItem
|
||||
:icon="item.icon"
|
||||
:badge="item.badge"
|
||||
:active="activeItem === item.id"
|
||||
@click="activeItem = item.id"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NavItem>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,8 +46,6 @@ import NavItem from '@/components/widget/nav/NavItem.vue'
|
||||
import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
|
||||
import PanelHeader from './PanelHeader.vue'
|
||||
|
||||
const { navItems = [], modelValue } = defineProps<{
|
||||
navItems?: (NavItemData | NavGroupData)[]
|
||||
modelValue?: string | null
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<header class="flex h-16 items-center justify-between px-6">
|
||||
<div class="flex items-center gap-2 pl-1">
|
||||
<slot name="icon">
|
||||
<i class="text-neutral icon-[lucide--puzzle] text-base" />
|
||||
</slot>
|
||||
<h2 class="text-neutral text-base font-bold">
|
||||
<slot></slot>
|
||||
</h2>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||