mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
feat(workspace): add UI panels, popovers, and integration layer
Panels & Popovers: - Add CurrentUserPopoverWorkspace for workspace-aware user menu - Add WorkspaceSwitcherPopover for switching workspaces - Add WorkspacePanel, WorkspacePanelContent, WorkspaceSidebarItem - Add MembersPanelContent for member management - Add SubscriptionPanelContentWorkspace and SubscriptionPanelContentLegacy - Add WorkspaceCreatedToast Integration Layer: - Update dialogService with workspace dialog methods - Update useSettingUI for workspace panel visibility - Update SettingDialogContent for workspace mode layout - Update SubscriptionPanel to switch between Legacy/Workspace content - Update GlobalDialog styling for workspace mode - Add all workspace i18n strings
This commit is contained in:
@@ -4,7 +4,12 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="item.dialogComponentProps.pt"
|
||||
:aria-labelledby="item.key"
|
||||
@@ -38,8 +43,13 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
</script>
|
||||
|
||||
@@ -55,4 +65,27 @@ const dialogStore = useDialogStore()
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
.settings-dialog-workspace {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
}
|
||||
|
||||
.settings-dialog-workspace .p-dialog-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
483
src/components/dialog/content/setting/MembersPanelContent.vue
Normal file
483
src/components/dialog/content/setting/MembersPanelContent.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full flex-col gap-2 rounded-2xl border border-border-default 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'">
|
||||
<!-- Current user (always pinned at top, no date) -->
|
||||
<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
|
||||
v-if="isPersonalWorkspace"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
v-if="uiConfig.showRoleBadge"
|
||||
class="py-0.5 px-1.5 text-xs bg-background-muted"
|
||||
>
|
||||
{{ workspaceRole }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ userEmail }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty cell for grid alignment (no date for current user) -->
|
||||
<span v-if="uiConfig.showDateColumn" />
|
||||
<!-- Empty cell for action column (can't remove yourself) -->
|
||||
<span v-if="permissions.canRemoveMembers" />
|
||||
</div>
|
||||
|
||||
<!-- Other members (sorted) -->
|
||||
<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">
|
||||
<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">
|
||||
{{ member.name.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
</span>
|
||||
<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) -->
|
||||
<div
|
||||
v-if="permissions.canRemoveMembers"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<Button
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, ref, watch } 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 { fetchMembers, fetchPendingInvites, copyInviteLink } = workspaceStore
|
||||
const { permissions, uiConfig, workspaceRole } = 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)
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
await Promise.all([fetchMembers(), fetchPendingInvites()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshData()
|
||||
})
|
||||
|
||||
watch(workspaceRole, () => {
|
||||
// Reset to active view if pending tab is not available for this role
|
||||
if (!uiConfig.value.showPendingTab) {
|
||||
activeView.value = 'active'
|
||||
}
|
||||
void refreshData()
|
||||
})
|
||||
|
||||
// Other members (sorted, excluding current user)
|
||||
const filteredMembers = computed(() => {
|
||||
let result = members.value.filter(
|
||||
(member) => member.email.toLowerCase() !== userEmail.value?.toLowerCase()
|
||||
)
|
||||
|
||||
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) => {
|
||||
const aValue = a.joinDate.getTime()
|
||||
const bValue = b.joinDate.getTime()
|
||||
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
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 as 'inviteDate' | 'expiryDate'
|
||||
result.sort((a, b) => {
|
||||
const aValue = a[field].getTime()
|
||||
const bValue = b[field].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
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>
|
||||
201
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
201
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="pb-8 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 !text-3xl"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</div>
|
||||
<Tabs :value="activeTab" @update:value="setActiveTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabList class="w-full">
|
||||
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab>
|
||||
<Tab value="members">{{
|
||||
$t('workspacePanel.tabs.membersCount', { count: 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 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
item.class,
|
||||
item.disabled ? 'pointer-events-auto' : ''
|
||||
]"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
item
|
||||
})
|
||||
"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContent />
|
||||
</TabPanel>
|
||||
<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 SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showInviteMemberDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
|
||||
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>
|
||||
44
src/components/toast/WorkspaceCreatedToast.vue
Normal file
44
src/components/toast/WorkspaceCreatedToast.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<Toast group="workspace-created">
|
||||
<template #message>
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-semibold text-base-foreground">
|
||||
{{ $t('workspacePanel.toast.workspaceCreated.title') }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.toast.workspaceCreated.message') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="muted-textonly" size="sm" @click="handleDismiss">
|
||||
{{ $t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" @click="handleSubscribe">
|
||||
{{ $t('workspacePanel.toast.workspaceCreated.subscribe') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const toast = useToast()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
function handleDismiss() {
|
||||
toast.removeGroup('workspace-created')
|
||||
}
|
||||
|
||||
function handleSubscribe() {
|
||||
toast.removeGroup('workspace-created')
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
}
|
||||
</script>
|
||||
347
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
347
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<!-- 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>
|
||||
<!-- <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> -->
|
||||
</div>
|
||||
|
||||
<!-- Workspace Selector -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
|
||||
@click="toggleWorkspaceSwitcher"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 shrink-0 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<span class="truncate text-sm text-base-foreground">{{
|
||||
workspaceName
|
||||
}}</span>
|
||||
<div
|
||||
v-if="workspaceTierName"
|
||||
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{{ workspaceTierName }}
|
||||
</div>
|
||||
<span v-else class="shrink-0 text-xs text-muted-foreground">
|
||||
{{ $t('workspaceSwitcher.subscribe') }}
|
||||
</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="workspaceSwitcherPopover"
|
||||
append-to="body"
|
||||
:pt="{
|
||||
content: {
|
||||
class: 'p-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<WorkspaceSwitcherPopover
|
||||
@select="workspaceSwitcherPopover?.hide()"
|
||||
@create="handleCreateWorkspace"
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<!-- Credits Section (PERSONAL and OWNER only) -->
|
||||
<template v-if="showCreditsSection">
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
displayedCredits
|
||||
}}</span>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<!-- Subscribed: Show Add Credits button -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && isWorkspaceSubscribed"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<!-- Unsubscribed: Show Subscribe button -->
|
||||
<SubscribeButton
|
||||
v-else
|
||||
:fluid="false"
|
||||
:label="$t('workspaceSwitcher.subscribe')"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
</template>
|
||||
|
||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||
<div
|
||||
v-if="showPlansAndPricing"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="plans-pricing-menu-item"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.plansAndPricing')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="canUpgrade"
|
||||
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
|
||||
>
|
||||
{{ $t('subscription.upgrade') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
|
||||
<div
|
||||
v-if="showManagePlan"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="manage-plan-menu-item"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
>
|
||||
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.managePlan')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Partner Nodes Pricing (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
>
|
||||
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.partnerNodesCredits')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Workspace Settings (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="workspace-settings-menu-item"
|
||||
@click="handleOpenWorkspaceSettings"
|
||||
>
|
||||
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('userSettings.workspaceSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Account Settings (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="user-settings-menu-item"
|
||||
@click="handleOpenUserSettings"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('userSettings.accountSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Logout (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="logout-menu-item"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('auth.signOut.signOut')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
isInPersonalWorkspace: isPersonalWorkspace,
|
||||
isWorkspaceSubscribed,
|
||||
subscriptionPlan
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { workspaceRole } = useWorkspaceUI()
|
||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { t } = useI18n()
|
||||
|
||||
const displayedCredits = computed(() =>
|
||||
isWorkspaceSubscribed.value ? totalCredits.value : '0'
|
||||
)
|
||||
|
||||
// Workspace subscription tier name (not user tier)
|
||||
const workspaceTierName = computed(() => {
|
||||
if (!isWorkspaceSubscribed.value) return null
|
||||
if (!subscriptionPlan.value) return null
|
||||
// Convert plan to display name
|
||||
if (subscriptionPlan.value === 'PRO_MONTHLY')
|
||||
return t('subscription.tiers.pro.name')
|
||||
if (subscriptionPlan.value === 'PRO_YEARLY')
|
||||
return t('subscription.tierNameYearly', {
|
||||
name: t('subscription.tiers.pro.name')
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
// PRO is currently the only/highest tier, so no upgrades available
|
||||
// This will need updating when additional tiers are added
|
||||
return false
|
||||
})
|
||||
|
||||
const showPlansAndPricing = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
const showManagePlan = computed(
|
||||
() => showPlansAndPricing.value && isActiveSubscription.value
|
||||
)
|
||||
const showCreditsSection = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenWorkspaceSettings = () => {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.show()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else {
|
||||
dialogService.showSettingsDialog('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await handleSignOut()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSubscribed = async () => {
|
||||
await fetchStatus()
|
||||
}
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
workspaceSwitcherPopover.value?.hide()
|
||||
dialogService.showCreateWorkspaceDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||
workspaceSwitcherPopover.value?.toggle(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
</script>
|
||||
192
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
192
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<!-- Loading state -->
|
||||
<div v-if="isFetchingWorkspaces" class="flex flex-col gap-2 p-2">
|
||||
<div
|
||||
v-for="i in 2"
|
||||
:key="i"
|
||||
class="flex h-[54px] animate-pulse items-center gap-2 rounded px-2 py-4"
|
||||
>
|
||||
<div class="size-8 rounded-full bg-secondary-background" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="h-4 w-24 rounded bg-secondary-background" />
|
||||
<div class="h-3 w-16 rounded bg-secondary-background" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace list -->
|
||||
<template v-else>
|
||||
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
|
||||
<div class="border-b border-border-default p-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group flex h-[54px] w-full items-center gap-2 rounded px-2 py-4',
|
||||
'hover:bg-secondary-background-hover',
|
||||
isCurrentWorkspace(workspace) && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
|
||||
@click="handleSelectWorkspace(workspace)"
|
||||
>
|
||||
<WorkspaceProfilePic
|
||||
class="size-8 text-sm"
|
||||
:workspace-name="workspace.name"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ workspace.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="workspace.type !== 'personal'"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ getRoleLabel(workspace.role) }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="isCurrentWorkspace(workspace)"
|
||||
class="pi pi-check text-sm text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
<!-- Delete button - only for team workspaces where user is owner -->
|
||||
<button
|
||||
v-if="canDeleteWorkspace(workspace)"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground opacity-0 transition-opacity hover:bg-error-background hover:text-error-foreground group-hover:opacity-100"
|
||||
:title="$t('g.delete')"
|
||||
@click.stop="handleDeleteWorkspace(workspace)"
|
||||
>
|
||||
<i class="pi pi-trash text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- <Divider class="mx-0 my-0" /> -->
|
||||
|
||||
<!-- Create workspace button -->
|
||||
<div class="px-2 py-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
|
||||
canCreateWorkspace
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'cursor-default'
|
||||
)
|
||||
"
|
||||
@click="canCreateWorkspace && handleCreateWorkspace()"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
|
||||
!canCreateWorkspace && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="pi pi-plus text-sm text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
v-if="canCreateWorkspace"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.createWorkspace') }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import type {
|
||||
WorkspaceRole,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface AvailableWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
role: WorkspaceRole
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [workspace: AvailableWorkspace]
|
||||
create: []
|
||||
delete: [workspace: AvailableWorkspace]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
const { showDeleteWorkspaceDialog } = useDialogService()
|
||||
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
|
||||
}))
|
||||
)
|
||||
|
||||
// Workspace store is initialized in router.ts before the app loads
|
||||
// This component just displays the already-loaded workspace data
|
||||
|
||||
function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
|
||||
return workspace.id === workspaceId.value
|
||||
}
|
||||
|
||||
function getRoleLabel(role: AvailableWorkspace['role']): string {
|
||||
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
|
||||
if (role === 'member') return t('workspaceSwitcher.roleMember')
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
const success = await switchWithConfirmation(workspace.id)
|
||||
if (success) {
|
||||
emit('select', workspace)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateWorkspace() {
|
||||
emit('create')
|
||||
}
|
||||
|
||||
function canDeleteWorkspace(workspace: AvailableWorkspace): boolean {
|
||||
// Can only delete team workspaces where user is owner
|
||||
return workspace.type === 'team' && workspace.role === 'owner'
|
||||
}
|
||||
|
||||
function handleDeleteWorkspace(workspace: AvailableWorkspace) {
|
||||
showDeleteWorkspaceDialog({
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name
|
||||
})
|
||||
emit('delete', workspace)
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"g": {
|
||||
"user": "User",
|
||||
"you": "You",
|
||||
"currentUser": "Current user",
|
||||
"empty": "Empty",
|
||||
"noWorkflowsFound": "No workflows found.",
|
||||
@@ -1264,7 +1265,10 @@
|
||||
"Scene": "Scene",
|
||||
"3D": "3D",
|
||||
"Light": "Light",
|
||||
"User": "User",
|
||||
"Profile": "Profile",
|
||||
"Workspace": "Workspace",
|
||||
"WorkspacePlan": "Plan & Credits",
|
||||
"WorkspaceMembers": "Members",
|
||||
"Credits": "Credits",
|
||||
"API Nodes": "API Nodes",
|
||||
"Notification Preferences": "Notification Preferences",
|
||||
@@ -1997,6 +2001,8 @@
|
||||
"renewsDate": "Renews {date}",
|
||||
"expiresDate": "Expires {date}",
|
||||
"manageSubscription": "Manage subscription",
|
||||
"managePayment": "Manage Payment",
|
||||
"cancelSubscription": "Cancel Subscription",
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
"partnerNodesDescription": "For running commercial/proprietary models",
|
||||
"totalCredits": "Total credits",
|
||||
@@ -2051,6 +2057,9 @@
|
||||
"subscribeToRunFull": "Subscribe to Run",
|
||||
"subscribeNow": "Subscribe Now",
|
||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||
"workspaceNotSubscribed": "This workspace is not on a subscription",
|
||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
|
||||
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
|
||||
"description": "Choose the best plan for you",
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
@@ -2086,12 +2095,132 @@
|
||||
"userSettings": {
|
||||
"title": "My Account Settings",
|
||||
"accountSettings": "Account settings",
|
||||
"workspaceSettings": "Workspace settings",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"provider": "Sign-in Provider",
|
||||
"notSet": "Not set",
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of 50 members",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
"membersCount": "Members ({count})"
|
||||
},
|
||||
"dashboard": {
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"membersCount": "{count}/50 Members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
"pendingCount": "Pending ({count})"
|
||||
},
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"joinDate": "Join date"
|
||||
},
|
||||
"actions": {
|
||||
"copyLink": "Copy invite link",
|
||||
"revokeInvite": "Revoke invite",
|
||||
"removeMember": "Remove member"
|
||||
},
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
|
||||
"createNewWorkspace": "create a new one."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
"nameLabel": "Workspace name",
|
||||
"save": "Save"
|
||||
},
|
||||
"leaveDialog": {
|
||||
"title": "Leave this workspace?",
|
||||
"message": "You won't be able to join again unless you contact the workspace owner.",
|
||||
"leave": "Leave"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete this workspace?",
|
||||
"message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.",
|
||||
"messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone."
|
||||
},
|
||||
"removeMemberDialog": {
|
||||
"title": "Remove this member?",
|
||||
"message": "This member will be removed from your workspace. Credits they've used will not be refunded.",
|
||||
"remove": "Remove member"
|
||||
},
|
||||
"revokeInviteDialog": {
|
||||
"title": "Uninvite this person?",
|
||||
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
|
||||
"revoke": "Uninvite"
|
||||
},
|
||||
"inviteMemberDialog": {
|
||||
"title": "Invite a person to this workspace",
|
||||
"message": "Create a shareable invite link to send to someone",
|
||||
"placeholder": "Enter the person's email",
|
||||
"createLink": "Create link",
|
||||
"linkStep": {
|
||||
"title": "Send this link to the person",
|
||||
"message": "Make sure their account uses this email.",
|
||||
"copyLink": "Copy Link",
|
||||
"done": "Done"
|
||||
},
|
||||
"linkCopied": "Copied",
|
||||
"linkCopyFailed": "Failed to copy link"
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"title": "Create a new workspace",
|
||||
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
|
||||
"nameLabel": "Workspace name*",
|
||||
"namePlaceholder": "Enter workspace name",
|
||||
"create": "Create"
|
||||
},
|
||||
"toast": {
|
||||
"workspaceCreated": {
|
||||
"title": "Workspace created",
|
||||
"message": "Subscribe to a plan, invite teammates, and start collaborating.",
|
||||
"subscribe": "Subscribe"
|
||||
},
|
||||
"workspaceUpdated": {
|
||||
"title": "Workspace updated",
|
||||
"message": "Workspace details have been saved."
|
||||
},
|
||||
"workspaceDeleted": {
|
||||
"title": "Workspace deleted",
|
||||
"message": "The workspace has been permanently deleted."
|
||||
},
|
||||
"workspaceLeft": {
|
||||
"title": "Left workspace",
|
||||
"message": "You have left the workspace."
|
||||
},
|
||||
"failedToUpdateWorkspace": "Failed to update workspace",
|
||||
"failedToCreateWorkspace": "Failed to create workspace",
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
"failedToLeaveWorkspace": "Failed to leave workspace",
|
||||
"failedToFetchWorkspaces": "Failed to load workspaces"
|
||||
}
|
||||
},
|
||||
"workspaceSwitcher": {
|
||||
"switchWorkspace": "Switch workspace",
|
||||
"subscribe": "Subscribe",
|
||||
"roleOwner": "Owner",
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create new workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one."
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
|
||||
@@ -2608,7 +2737,10 @@
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
|
||||
}
|
||||
},
|
||||
"inviteAccepted": "Invite Accepted",
|
||||
"addedToWorkspace": "You have been added to {workspaceName}",
|
||||
"inviteFailed": "Failed to Accept Invite"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
"errors": {
|
||||
@@ -2619,4 +2751,4 @@
|
||||
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,208 +17,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grow overflow-auto">
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{
|
||||
$t('subscription.perMonth')
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('subscription.manageSubscription') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
|
||||
<SubscribeButton
|
||||
v-else
|
||||
:label="$t('subscription.subscribeNow')"
|
||||
size="sm"
|
||||
:fluid="false"
|
||||
class="text-xs"
|
||||
@subscribed="handleRefresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6 pt-9">
|
||||
<div class="flex flex-col shrink-0">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-modal-panel-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="8rem"
|
||||
height="2rem"
|
||||
/>
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ includedCreditsDisplay }}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ prepaidCredits }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
:href="usageHistoryUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0">
|
||||
<div
|
||||
v-for="benefit in tierBenefits"
|
||||
:key="benefit.key"
|
||||
class="flex items-center gap-2 py-2"
|
||||
>
|
||||
<i
|
||||
v-if="benefit.type === 'feature'"
|
||||
class="pi pi-check text-xs text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-else-if="benefit.type === 'metric' && benefit.value"
|
||||
class="text-sm font-normal whitespace-nowrap text-text-primary"
|
||||
>
|
||||
{{ benefit.value }}
|
||||
</span>
|
||||
<span class="text-sm text-muted">
|
||||
{{ benefit.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View More Details - Outside main content -->
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<i class="pi pi-external-link text-muted"></i>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline hover:opacity-80 text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewMoreDetailsPlans') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Workspace mode: workspace-aware subscription content -->
|
||||
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
|
||||
<!-- Legacy mode: user-level subscription content -->
|
||||
<SubscriptionPanelContentLegacy v-else />
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-interface-stroke pt-3"
|
||||
@@ -265,171 +67,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierFeatures,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue')
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
isYearlySubscription,
|
||||
handleInvoiceHistory
|
||||
} = useSubscription()
|
||||
const { isActiveSubscription, handleInvoiceHistory } = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
const usageHistoryUrl = computed(
|
||||
() => `${getComfyPlatformBaseUrl()}/profile/usage`
|
||||
)
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t('subscription.creditsRemainingThisYear', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
: t('subscription.creditsRemainingThisMonth', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
)
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t(`subscription.maxDuration.${key}`),
|
||||
label: t('subscription.maxDurationLabel')
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.gpuLabel')
|
||||
},
|
||||
{
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
]
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.customLoRAsLabel')
|
||||
})
|
||||
}
|
||||
|
||||
return benefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const {
|
||||
isLoadingSupport,
|
||||
handleAddApiCredits,
|
||||
handleMessageSupport,
|
||||
handleRefresh,
|
||||
handleLearnMoreClick
|
||||
} = useSubscriptionActions()
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
|
||||
useSubscriptionActions()
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
@@ -438,9 +101,3 @@ const handleOpenPartnerNodesInfo = () => {
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto">
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('subscription.manageSubscription') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
|
||||
<SubscribeButton
|
||||
v-if="!isActiveSubscription"
|
||||
:label="$t('subscription.subscribeNow')"
|
||||
size="sm"
|
||||
:fluid="false"
|
||||
class="text-xs"
|
||||
@subscribed="handleRefresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6 pt-9">
|
||||
<div class="flex flex-col shrink-0">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-modal-panel-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ includedCreditsDisplay }}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ prepaidCredits }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0">
|
||||
<div
|
||||
v-for="benefit in tierBenefits"
|
||||
:key="benefit.key"
|
||||
class="flex items-center gap-2 py-2"
|
||||
>
|
||||
<i
|
||||
v-if="benefit.type === 'feature'"
|
||||
class="pi pi-check text-xs text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-else-if="benefit.type === 'metric' && benefit.value"
|
||||
class="text-sm font-normal whitespace-nowrap text-text-primary"
|
||||
>
|
||||
{{ benefit.value }}
|
||||
</span>
|
||||
<span class="text-sm text-muted">
|
||||
{{ benefit.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View More Details - Outside main content -->
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<i class="pi pi-external-link text-muted"></i>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline hover:opacity-80 text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewMoreDetailsPlans') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierFeatures,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t('subscription.creditsRemainingThisYear', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
: t('subscription.creditsRemainingThisMonth', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
)
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t(`subscription.maxDuration.${key}`),
|
||||
label: t('subscription.maxDurationLabel')
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.gpuLabel')
|
||||
},
|
||||
{
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
]
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.customLoRAsLabel')
|
||||
})
|
||||
}
|
||||
|
||||
return benefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,435 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto">
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<!-- OWNER Unsubscribed State -->
|
||||
<template v-if="isOwnerUnsubscribed">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.subscriptionRequiredMessage') }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
|
||||
@click="handleSubscribeWorkspace"
|
||||
>
|
||||
{{ $t('subscription.subscribeNow') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- MEMBER View - read-only, no subscription data yet -->
|
||||
<template v-else-if="isMemberView">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('subscription.contactOwnerToSubscribe') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal Subscribed State (Owner with subscription) -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-text-primary">
|
||||
{{ subscriptionTierName }}
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||
<span class="text-2xl">${{ tierPrice }}</span>
|
||||
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
{{
|
||||
$t('subscription.expiresDate', {
|
||||
date: formattedEndDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('subscription.renewsDate', {
|
||||
date: formattedRenewalDate
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="
|
||||
async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('subscription.managePayment') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="planMenu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6 pt-9">
|
||||
<div class="flex flex-col shrink-0">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-modal-panel-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ showZeroState ? '0' : totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0 / 0' : includedCreditsDisplay
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 font-bold text-left align-middle">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{
|
||||
showZeroState ? '0' : prepaidCredits
|
||||
}}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline text-center text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription && !showZeroState"
|
||||
variant="secondary"
|
||||
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0">
|
||||
<div
|
||||
v-for="benefit in tierBenefits"
|
||||
:key="benefit.key"
|
||||
class="flex items-center gap-2 py-2"
|
||||
>
|
||||
<i
|
||||
v-if="benefit.type === 'feature'"
|
||||
class="pi pi-check text-xs text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-else-if="benefit.type === 'metric' && benefit.value"
|
||||
class="text-sm font-normal whitespace-nowrap text-text-primary"
|
||||
>
|
||||
{{ benefit.value }}
|
||||
</span>
|
||||
<span class="text-sm text-muted">
|
||||
{{ benefit.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View More Details - Outside main content -->
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<i class="pi pi-external-link text-muted"></i>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline hover:opacity-80 text-muted"
|
||||
>
|
||||
{{ $t('subscription.viewMoreDetailsPlans') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierFeatures,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore)
|
||||
const { subscribeWorkspace } = workspaceStore
|
||||
const { permissions, workspaceRole } = useWorkspaceUI()
|
||||
const { t, n } = useI18n()
|
||||
|
||||
// OWNER with unsubscribed workspace - can see subscribe button
|
||||
const isOwnerUnsubscribed = computed(
|
||||
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
// MEMBER view - members can't manage subscription, show read-only zero state
|
||||
const isMemberView = computed(() => !permissions.value.canManageSubscription)
|
||||
|
||||
// Show zero state for credits (no real billing data yet)
|
||||
const showZeroState = computed(
|
||||
() => isOwnerUnsubscribed.value || isMemberView.value
|
||||
)
|
||||
|
||||
// Demo: Subscribe workspace to PRO monthly plan
|
||||
function handleSubscribeWorkspace() {
|
||||
subscribeWorkspace('PRO_MONTHLY')
|
||||
}
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
const planMenuItems = computed(() => [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t('subscription.creditsRemainingThisYear', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
: t('subscription.creditsRemainingThisMonth', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
)
|
||||
|
||||
// Tier benefits for v-for loop
|
||||
type BenefitType = 'metric' | 'feature'
|
||||
|
||||
interface Benefit {
|
||||
key: string
|
||||
type: BenefitType
|
||||
label: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const tierBenefits = computed((): Benefit[] => {
|
||||
const key = tierKey.value
|
||||
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: t(`subscription.maxDuration.${key}`),
|
||||
label: t('subscription.maxDurationLabel')
|
||||
},
|
||||
{
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: t('subscription.gpuLabel')
|
||||
},
|
||||
{
|
||||
key: 'addCredits',
|
||||
type: 'feature',
|
||||
label: t('subscription.addCreditsLabel')
|
||||
}
|
||||
]
|
||||
|
||||
if (getTierFeatures(key).customLoRAs) {
|
||||
benefits.push({
|
||||
key: 'customLoRAs',
|
||||
type: 'feature',
|
||||
label: t('subscription.customLoRAsLabel')
|
||||
})
|
||||
}
|
||||
|
||||
return benefits
|
||||
})
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bg-comfy-menu-secondary) {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,18 @@
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<ScrollPanel class="settings-sidebar w-48 shrink-0 p-2 2xl:w-64">
|
||||
<div
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'flex h-[80vh] w-full overflow-hidden'
|
||||
: 'settings-container'
|
||||
"
|
||||
>
|
||||
<ScrollPanel
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'w-48 shrink-0 p-2 2xl:w-64'
|
||||
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
|
||||
"
|
||||
>
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
class="settings-search-box mb-2 w-full"
|
||||
@@ -20,16 +32,40 @@
|
||||
(option: SettingTreeNode) =>
|
||||
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
|
||||
"
|
||||
class="w-full border-none"
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'w-full border-none bg-transparent'
|
||||
: 'w-full border-none'
|
||||
"
|
||||
>
|
||||
<template #optiongroup>
|
||||
<!-- Workspace mode: custom group headers -->
|
||||
<template v-if="teamWorkspacesEnabled" #optiongroup="{ option }">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
|
||||
{{ option.label }}
|
||||
</h3>
|
||||
</template>
|
||||
<!-- Legacy mode: divider between groups -->
|
||||
<template v-else #optiongroup>
|
||||
<Divider class="my-0" />
|
||||
</template>
|
||||
<!-- Workspace mode: custom workspace item -->
|
||||
<template v-if="teamWorkspacesEnabled" #option="{ option }">
|
||||
<WorkspaceSidebarItem v-if="option.key === 'workspace'" />
|
||||
<span v-else>{{ option.translatedLabel }}</span>
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
|
||||
<Divider layout="horizontal" class="flex md:hidden" />
|
||||
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
|
||||
<Tabs
|
||||
:value="tabValue"
|
||||
:lazy="true"
|
||||
:class="
|
||||
teamWorkspacesEnabled
|
||||
? 'h-full flex-1 overflow-x-auto'
|
||||
: 'settings-content h-full w-full'
|
||||
"
|
||||
>
|
||||
<TabPanels class="settings-tab-panels h-full w-full pr-0">
|
||||
<PanelTemplate value="Search Results">
|
||||
<SettingsPanel :setting-groups="searchResults" />
|
||||
@@ -48,7 +84,7 @@
|
||||
</PanelTemplate>
|
||||
|
||||
<Suspense v-for="panel in panels" :key="panel.node.key">
|
||||
<component :is="panel.component" />
|
||||
<component :is="panel.component" v-bind="panel.props" />
|
||||
<template #fallback>
|
||||
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
|
||||
</template>
|
||||
@@ -69,7 +105,10 @@ import { computed, watch } from 'vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
|
||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
||||
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
@@ -86,8 +125,13 @@ const { defaultPanel } = defineProps<{
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||
|
||||
const {
|
||||
activeCategory,
|
||||
defaultCategory,
|
||||
@@ -162,6 +206,7 @@ watch(activeCategory, (_, oldValue) => {
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
|
||||
.settings-container {
|
||||
display: flex;
|
||||
height: 70vh;
|
||||
@@ -190,7 +235,7 @@ watch(activeCategory, (_, oldValue) => {
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the first group separator */
|
||||
/* Hide the first group separator in legacy mode */
|
||||
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,21 @@ import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
component: Component
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useSettingUI(
|
||||
@@ -27,15 +29,19 @@ export function useSettingUI(
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const settingStore = useSettingStore()
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
|
||||
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
Object.values(settingStore.settingsById).filter(
|
||||
@@ -64,6 +70,33 @@ export function useSettingUI(
|
||||
() => settingRoot.value.children ?? []
|
||||
)
|
||||
|
||||
// Core setting categories (built-in to ComfyUI) in display order
|
||||
// 'Other' includes floating settings that don't have a specific category
|
||||
const CORE_CATEGORIES_ORDER = [
|
||||
'Comfy',
|
||||
'LiteGraph',
|
||||
'Appearance',
|
||||
'3D',
|
||||
'Mask Editor',
|
||||
'Other'
|
||||
]
|
||||
const CORE_CATEGORIES = new Set(CORE_CATEGORIES_ORDER)
|
||||
|
||||
const coreSettingCategories = computed<SettingTreeNode[]>(() => {
|
||||
const categories = settingCategories.value.filter((node) =>
|
||||
CORE_CATEGORIES.has(node.label)
|
||||
)
|
||||
return categories.sort(
|
||||
(a, b) =>
|
||||
CORE_CATEGORIES_ORDER.indexOf(a.label) -
|
||||
CORE_CATEGORIES_ORDER.indexOf(b.label)
|
||||
)
|
||||
})
|
||||
|
||||
const customNodeSettingCategories = computed<SettingTreeNode[]>(() =>
|
||||
settingCategories.value.filter((node) => !CORE_CATEGORIES.has(node.label))
|
||||
)
|
||||
|
||||
// Define panel items
|
||||
const aboutPanel: SettingPanelItem = {
|
||||
node: {
|
||||
@@ -118,6 +151,25 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
// Workspace panel: only available on cloud with team workspaces enabled
|
||||
const workspacePanel: SettingPanelItem | null = !teamWorkspacesEnabled
|
||||
? null
|
||||
: {
|
||||
node: {
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/WorkspacePanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const shouldShowWorkspacePanel = computed(() => {
|
||||
if (!workspacePanel) return false
|
||||
return isLoggedIn.value
|
||||
})
|
||||
|
||||
const keybindingPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'keybinding',
|
||||
@@ -156,13 +208,16 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value && workspacePanel
|
||||
? [workspacePanel]
|
||||
: []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isElectron() ? [serverConfigPanel] : []),
|
||||
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
|
||||
? [subscriptionPanel]
|
||||
: [])
|
||||
].filter((panel) => panel.component)
|
||||
].filter((panel) => panel !== null && panel.component)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -186,7 +241,49 @@ export function useSettingUI(
|
||||
)
|
||||
})
|
||||
|
||||
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||
// Sidebar structure when team workspaces is enabled
|
||||
const workspaceMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||
// Workspace settings
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value && workspacePanel
|
||||
? [workspacePanel.node]
|
||||
: []),
|
||||
...(isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
: [])
|
||||
].map(translateCategory)
|
||||
},
|
||||
// General settings - Profile + all core settings + special panels
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
children: [
|
||||
translateCategory(userPanel.node),
|
||||
...coreSettingCategories.value.map(translateCategory),
|
||||
translateCategory(keybindingPanel.node),
|
||||
translateCategory(extensionPanel.node),
|
||||
translateCategory(aboutPanel.node),
|
||||
...(isElectron() ? [translateCategory(serverConfigPanel.node)] : [])
|
||||
]
|
||||
},
|
||||
// Custom node settings (only shown if custom nodes have registered settings)
|
||||
...(customNodeSettingCategories.value.length > 0
|
||||
? [
|
||||
{
|
||||
key: 'other',
|
||||
label: 'Other',
|
||||
children: customNodeSettingCategories.value.map(translateCategory)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
|
||||
// Sidebar structure when team workspaces is disabled (legacy)
|
||||
const legacyMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||
// Account settings - show different panels based on distribution and auth state
|
||||
{
|
||||
key: 'account',
|
||||
@@ -223,6 +320,12 @@ export function useSettingUI(
|
||||
}
|
||||
])
|
||||
|
||||
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() =>
|
||||
teamWorkspacesEnabled
|
||||
? workspaceMenuTreeNodes.value
|
||||
: legacyMenuTreeNodes.value
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
activeCategory.value = defaultCategory.value
|
||||
})
|
||||
|
||||
@@ -102,6 +102,7 @@ export const useDialogService = () => {
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
| 'workspace'
|
||||
) {
|
||||
const props = panel ? { props: { defaultPanel: panel } } : undefined
|
||||
|
||||
@@ -519,6 +520,113 @@ export const useDialogService = () => {
|
||||
show()
|
||||
}
|
||||
|
||||
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
|
||||
const workspaceDialogPt = {
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
} as const
|
||||
|
||||
async function showLeaveWorkspaceDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'leave-workspace',
|
||||
component,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
async function showDeleteWorkspaceDialog(options?: {
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
}) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'delete-workspace',
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
async function showRemoveMemberDialog(memberId: string) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/RemoveMemberDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'remove-member',
|
||||
component,
|
||||
props: { memberId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
async function showRevokeInviteDialog(inviteId: string) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'revoke-invite',
|
||||
component,
|
||||
props: { inviteId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
async function showInviteMemberDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/InviteMemberDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'invite-member',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt,
|
||||
pt: {
|
||||
...workspaceDialogPt.pt,
|
||||
root: { class: 'rounded-2xl max-w-[512px] w-full' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function showCreateWorkspaceDialog(
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'create-workspace',
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt,
|
||||
pt: {
|
||||
...workspaceDialogPt.pt,
|
||||
root: { class: 'rounded-2xl max-w-[400px] w-full' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function showEditWorkspaceDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'edit-workspace',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt,
|
||||
pt: {
|
||||
...workspaceDialogPt.pt,
|
||||
root: { class: 'rounded-2xl max-w-[400px] w-full' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
showLoadWorkflowWarning,
|
||||
showMissingModelsWarning,
|
||||
@@ -530,6 +638,13 @@ export const useDialogService = () => {
|
||||
showSubscriptionRequiredDialog,
|
||||
showTopUpCreditsDialog,
|
||||
showUpdatePasswordDialog,
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showRemoveMemberDialog,
|
||||
showRevokeInviteDialog,
|
||||
showInviteMemberDialog,
|
||||
showCreateWorkspaceDialog,
|
||||
showEditWorkspaceDialog,
|
||||
showExtensionDialog,
|
||||
prompt,
|
||||
showErrorDialog,
|
||||
|
||||
Reference in New Issue
Block a user