mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-24 00:09:32 +00:00
feat: implemented workspace flow
This commit is contained in:
@@ -21,114 +21,197 @@
|
||||
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
<span
|
||||
<!-- <span
|
||||
v-if="subscriptionTierName"
|
||||
class="my-0 text-xs text-foreground bg-secondary-background-hover rounded-full uppercase px-2 py-0.5 font-bold mt-2"
|
||||
>
|
||||
{{ subscriptionTierName }}
|
||||
</span>
|
||||
</span> -->
|
||||
</div>
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-amber-400 text-sm" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="icon-[lucide--circle-help] cursor-help text-base text-muted-foreground mr-auto"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex justify-center px-4">
|
||||
<SubscribeButton
|
||||
:fluid="false"
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider class="my-2 mx-0" />
|
||||
|
||||
<!-- Workspace Selector -->
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
|
||||
@click="toggleWorkspaceSwitcher"
|
||||
>
|
||||
<i class="icon-[lucide--tag] text-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
$t('subscription.partnerNodesCredits')
|
||||
}}</span>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 shrink-0 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<span class="truncate text-sm text-base-foreground">{{
|
||||
workspaceName
|
||||
}}</span>
|
||||
<div
|
||||
v-if="subscriptionTierName"
|
||||
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{{ subscriptionTierName }}
|
||||
</div>
|
||||
<span v-else class="shrink-0 text-xs text-muted-foreground">
|
||||
{{ $t('workspaceSwitcher.subscribe') }}
|
||||
</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="workspaceSwitcherPopover"
|
||||
append-to="body"
|
||||
:pt="{
|
||||
content: {
|
||||
class: 'p-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<WorkspaceSwitcherPopover
|
||||
@select="workspaceSwitcherPopover?.hide()"
|
||||
@create="handleCreateWorkspace"
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<!-- Credits Section (PERSONAL and OWNER only) -->
|
||||
<template v-if="showCreditsSection">
|
||||
<!-- Subscribed: Show balance + Add credits -->
|
||||
<div
|
||||
v-if="isActiveSubscription && isWorkspaceSubscribed"
|
||||
class="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</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"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- OWNER unsubscribed: Show Subscribe button (primary) -->
|
||||
<div
|
||||
v-else-if="workspaceRole === 'OWNER' && !isWorkspaceSubscribed"
|
||||
class="flex justify-center px-4 py-2"
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
data-testid="subscribe-button"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
{{ $t('subscription.subscribeNow') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- PERSONAL unsubscribed: Show gradient SubscribeButton -->
|
||||
<div v-else class="flex justify-center px-4">
|
||||
<SubscribeButton
|
||||
:fluid="false"
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
</template>
|
||||
|
||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
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-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
<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="text-xs font-bold text-base-background bg-base-foreground px-1.5 py-0.5 rounded-full"
|
||||
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="isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
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-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
<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 items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
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-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
<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="my-2 mx-0" />
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Logout (always shown) -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||
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-muted-foreground text-sm" />
|
||||
<span class="text-sm text-base-foreground flex-1">{{
|
||||
<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>
|
||||
@@ -137,12 +220,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
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'
|
||||
@@ -152,9 +238,18 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const {
|
||||
workspaceName,
|
||||
workspaceRole,
|
||||
isPersonalWorkspace,
|
||||
isWorkspaceSubscribed
|
||||
} = useWorkspace()
|
||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
@@ -197,11 +292,31 @@ const canUpgrade = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// Menu visibility based on role
|
||||
// PERSONAL: Plans & pricing, Manage plan (if subscribed), Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
|
||||
// MEMBER: Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
|
||||
// OWNER (unsubscribed): Plans & pricing, Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
|
||||
// OWNER (subscribed): Plans & pricing, Manage plan, Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
|
||||
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')
|
||||
@@ -209,7 +324,7 @@ const handleOpenPlansAndPricing = () => {
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
dialogService.showSettingsDialog('workspace-plan')
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else {
|
||||
dialogService.showSettingsDialog('credits')
|
||||
}
|
||||
@@ -241,6 +356,18 @@ const handleSubscribed = async () => {
|
||||
await fetchStatus()
|
||||
}
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
workspaceSwitcherPopover.value?.hide()
|
||||
dialogService.showCreateWorkspaceDialog(() => {
|
||||
// TODO: Implement actual create workspace API call
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||
workspaceSwitcherPopover.value?.toggle(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
|
||||
123
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
123
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<template
|
||||
v-for="workspace in availableWorkspaces"
|
||||
:key="workspace.id ?? 'personal'"
|
||||
>
|
||||
<div class="border-b border-border-default p-2">
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'flex h-[54px] w-full cursor-pointer items-center gap-2 rounded px-2 py-4 border-none bg-transparent',
|
||||
'hover:bg-secondary-background-hover',
|
||||
isCurrentWorkspace(workspace) && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="handleSelectWorkspace(workspace)"
|
||||
>
|
||||
<WorkspaceProfilePic
|
||||
class="size-8 text-sm"
|
||||
:workspace-name="workspace.name"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ workspace.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="workspace.role !== 'PERSONAL'"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ getRoleLabel(workspace.role) }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="isCurrentWorkspace(workspace)"
|
||||
class="pi pi-check text-sm text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- <Divider class="mx-0 my-0" /> -->
|
||||
|
||||
<!-- Create workspace button -->
|
||||
<div class="px-2 py-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
|
||||
canCreateWorkspace
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'cursor-default'
|
||||
)
|
||||
"
|
||||
@click="canCreateWorkspace && handleCreateWorkspace()"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
|
||||
!canCreateWorkspace && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="pi pi-plus text-sm text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
v-if="canCreateWorkspace"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.createWorkspace') }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import type { AvailableWorkspace } from '@/platform/workspace/composables/useWorkspace'
|
||||
import { useWorkspace } from '@/platform/workspace/composables/useWorkspace'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [workspace: AvailableWorkspace]
|
||||
create: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
workspaceId,
|
||||
availableWorkspaces,
|
||||
canCreateWorkspace,
|
||||
switchWorkspace
|
||||
} = useWorkspace()
|
||||
|
||||
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 handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
switchWorkspace(workspace)
|
||||
emit('select', workspace)
|
||||
}
|
||||
|
||||
function handleCreateWorkspace() {
|
||||
emit('create')
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user