mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-24 00:34:09 +00:00
Cherry pick API node fixes (#3839)
Co-authored-by: Terry Jia <terryjia88@gmail.com> Co-authored-by: Chenlei Hu <hcl@comfy.org> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Chenlei Hu <huchenlei@proton.me> Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
<div class="flex flex-col items-center">
|
||||
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem" />
|
||||
<h3>{{ title }}</h3>
|
||||
<p class="whitespace-pre-line text-center">
|
||||
<p :class="textClass" class="whitespace-pre-line text-center">
|
||||
{{ message }}
|
||||
</p>
|
||||
<Button
|
||||
@@ -29,6 +29,7 @@ const props = defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
message: string
|
||||
textClass?: string
|
||||
buttonLabel?: string
|
||||
}>()
|
||||
|
||||
|
||||
106
src/components/common/UserAvatar.test.ts
Normal file
106
src/components/common/UserAvatar.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Avatar from 'primevue/avatar'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
auth: {
|
||||
login: {
|
||||
userAvatar: 'User Avatar'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('UserAvatar', () => {
|
||||
beforeEach(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any = {}) => {
|
||||
return mount(UserAvatar, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: { Avatar }
|
||||
},
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
it('renders correctly with photo Url', async () => {
|
||||
const wrapper = mountComponent({
|
||||
photoUrl: 'https://example.com/avatar.jpg'
|
||||
})
|
||||
|
||||
const avatar = wrapper.findComponent(Avatar)
|
||||
expect(avatar.exists()).toBe(true)
|
||||
expect(avatar.props('image')).toBe('https://example.com/avatar.jpg')
|
||||
expect(avatar.props('icon')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders with default icon when no photo Url is provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
photoUrl: undefined
|
||||
})
|
||||
|
||||
const avatar = wrapper.findComponent(Avatar)
|
||||
expect(avatar.exists()).toBe(true)
|
||||
expect(avatar.props('image')).toBeNull()
|
||||
expect(avatar.props('icon')).toBe('pi pi-user')
|
||||
})
|
||||
|
||||
it('renders with default icon when provided photo Url is null', () => {
|
||||
const wrapper = mountComponent({
|
||||
photoUrl: null
|
||||
})
|
||||
|
||||
const avatar = wrapper.findComponent(Avatar)
|
||||
expect(avatar.exists()).toBe(true)
|
||||
expect(avatar.props('image')).toBeNull()
|
||||
expect(avatar.props('icon')).toBe('pi pi-user')
|
||||
})
|
||||
|
||||
it('falls back to icon when image fails to load', async () => {
|
||||
const wrapper = mountComponent({
|
||||
photoUrl: 'https://example.com/broken-image.jpg'
|
||||
})
|
||||
|
||||
const avatar = wrapper.findComponent(Avatar)
|
||||
expect(avatar.props('icon')).toBeNull()
|
||||
|
||||
// Simulate image load error
|
||||
avatar.vm.$emit('error')
|
||||
await nextTick()
|
||||
|
||||
expect(avatar.props('icon')).toBe('pi pi-user')
|
||||
})
|
||||
|
||||
it('uses provided ariaLabel', () => {
|
||||
const wrapper = mountComponent({
|
||||
photoUrl: 'https://example.com/avatar.jpg',
|
||||
ariaLabel: 'Custom Label'
|
||||
})
|
||||
|
||||
const avatar = wrapper.findComponent(Avatar)
|
||||
expect(avatar.attributes('aria-label')).toBe('Custom Label')
|
||||
})
|
||||
|
||||
it('falls back to i18n translation when no ariaLabel is provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
photoUrl: 'https://example.com/avatar.jpg'
|
||||
})
|
||||
|
||||
const avatar = wrapper.findComponent(Avatar)
|
||||
expect(avatar.attributes('aria-label')).toBe('User Avatar')
|
||||
})
|
||||
})
|
||||
25
src/components/common/UserAvatar.vue
Normal file
25
src/components/common/UserAvatar.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<Avatar
|
||||
:image="photoUrl ?? undefined"
|
||||
:icon="hasAvatar ? undefined : 'pi pi-user'"
|
||||
shape="circle"
|
||||
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Avatar from 'primevue/avatar'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const { photoUrl, ariaLabel } = defineProps<{
|
||||
photoUrl?: string | null
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const imageError = ref(false)
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
}
|
||||
const hasAvatar = computed(() => photoUrl && !imageError.value)
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="title"
|
||||
:message="error.exceptionMessage"
|
||||
:text-class="'break-words max-w-[60vw]'"
|
||||
/>
|
||||
<template v-if="error.extensionFile">
|
||||
<span>{{ t('errorDialog.extensionFileHint') }}:</span>
|
||||
|
||||
@@ -1,95 +1,132 @@
|
||||
<template>
|
||||
<div class="w-96 p-2">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<h1 class="text-2xl font-medium leading-normal my-0">
|
||||
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
|
||||
</h1>
|
||||
<p class="text-base my-0">
|
||||
<span class="text-muted">{{
|
||||
isSignIn
|
||||
? t('auth.login.newUser')
|
||||
: t('auth.signup.alreadyHaveAccount')
|
||||
}}</span>
|
||||
<span class="ml-1 cursor-pointer text-blue-500" @click="toggleState">{{
|
||||
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
|
||||
<!-- Form -->
|
||||
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
|
||||
<div class="w-96 p-2 overflow-x-hidden">
|
||||
<ApiKeyForm
|
||||
v-if="showApiKeyForm"
|
||||
@back="showApiKeyForm = false"
|
||||
@success="onSuccess"
|
||||
/>
|
||||
<template v-else>
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<h1 class="text-2xl font-medium leading-normal my-0">
|
||||
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
|
||||
</h1>
|
||||
<p class="text-base my-0">
|
||||
<span class="text-muted">{{
|
||||
isSignIn
|
||||
? t('auth.login.newUser')
|
||||
: t('auth.signup.alreadyHaveAccount')
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-1 cursor-pointer text-blue-500"
|
||||
@click="toggleState"
|
||||
>{{
|
||||
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
|
||||
}}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
<SignUpForm v-else @submit="signUpWithEmail" />
|
||||
|
||||
<!-- Form -->
|
||||
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
|
||||
<template v-else>
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else @submit="signUpWithEmail" />
|
||||
</template>
|
||||
|
||||
<!-- Divider -->
|
||||
<Divider align="center" layout="horizontal" class="my-8">
|
||||
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
|
||||
</Divider>
|
||||
|
||||
<!-- Social Login Buttons -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{
|
||||
isSignIn
|
||||
? t('auth.login.loginWithGoogle')
|
||||
: t('auth.signup.signUpWithGoogle')
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
{{
|
||||
isSignIn
|
||||
? t('auth.login.loginWithGithub')
|
||||
: t('auth.signup.signUpWithGithub')
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="showApiKeyForm = true"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
class="w-5 h-5 mr-2"
|
||||
alt="Comfy"
|
||||
/>
|
||||
{{ t('auth.login.useApiKey') }}
|
||||
</Button>
|
||||
<small class="text-muted text-center">
|
||||
{{ t('auth.apiKey.helpText') }}
|
||||
<a
|
||||
href="https://platform.comfy.org/login"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.apiKey.generateKey') }}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
<p class="text-xs text-muted mt-8">
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
{{ t('auth.login.andText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
{{ t('auth.login.questionsContactPrefix') }}
|
||||
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
|
||||
hello@comfy.org</a
|
||||
>.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Divider -->
|
||||
<Divider align="center" layout="horizontal" class="my-8">
|
||||
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
|
||||
</Divider>
|
||||
|
||||
<!-- Social Login Buttons -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{
|
||||
isSignIn
|
||||
? t('auth.login.loginWithGoogle')
|
||||
: t('auth.signup.signUpWithGoogle')
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
{{
|
||||
isSignIn
|
||||
? t('auth.login.loginWithGithub')
|
||||
: t('auth.signup.signUpWithGithub')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Terms & Contact -->
|
||||
<p class="text-xs text-muted mt-8">
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
{{ t('auth.login.andText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
{{ t('auth.login.questionsContactPrefix') }}
|
||||
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
|
||||
hello@comfy.org</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,6 +141,7 @@ import { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
|
||||
import ApiKeyForm from './signin/ApiKeyForm.vue'
|
||||
import SignInForm from './signin/SignInForm.vue'
|
||||
import SignUpForm from './signin/SignUpForm.vue'
|
||||
|
||||
@@ -115,8 +153,11 @@ const { t } = useI18n()
|
||||
const authService = useFirebaseAuthService()
|
||||
const isSecureContext = window.isSecureContext
|
||||
const isSignIn = ref(true)
|
||||
const showApiKeyForm = ref(false)
|
||||
|
||||
const toggleState = () => {
|
||||
isSignIn.value = !isSignIn.value
|
||||
showApiKeyForm.value = false
|
||||
}
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<h2 class="text-2xl font-bold mb-2">{{ $t('userSettings.title') }}</h2>
|
||||
<Divider class="mb-3" />
|
||||
|
||||
<div v-if="user" class="flex flex-col gap-2">
|
||||
<Avatar
|
||||
v-if="user.photoURL"
|
||||
:image="user.photoURL"
|
||||
<!-- Normal User Panel -->
|
||||
<div v-if="isLoggedIn" class="flex flex-col gap-2">
|
||||
<UserAvatar
|
||||
v-if="userPhotoUrl"
|
||||
:photo-url="userPhotoUrl"
|
||||
shape="circle"
|
||||
size="large"
|
||||
aria-label="User Avatar"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
@@ -18,7 +18,7 @@
|
||||
{{ $t('userSettings.name') }}
|
||||
</h3>
|
||||
<div class="text-muted">
|
||||
{{ user.displayName || $t('userSettings.notSet') }}
|
||||
{{ userDisplayName || $t('userSettings.notSet') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
<h3 class="font-medium">
|
||||
{{ $t('userSettings.email') }}
|
||||
</h3>
|
||||
<a :href="'mailto:' + user.email" class="hover:underline">
|
||||
{{ user.email }}
|
||||
<a :href="'mailto:' + userEmail" class="hover:underline">
|
||||
{{ userEmail }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -87,55 +87,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Avatar from 'primevue/avatar'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const commandStore = useCommandStore()
|
||||
const user = computed(() => authStore.currentUser)
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const providerName = computed(() => {
|
||||
const providerId = user.value?.providerData[0]?.providerId
|
||||
if (providerId?.includes('google')) {
|
||||
return 'Google'
|
||||
}
|
||||
if (providerId?.includes('github')) {
|
||||
return 'GitHub'
|
||||
}
|
||||
return providerId
|
||||
})
|
||||
|
||||
const providerIcon = computed(() => {
|
||||
const providerId = user.value?.providerData[0]?.providerId
|
||||
if (providerId?.includes('google')) {
|
||||
return 'pi pi-google'
|
||||
}
|
||||
if (providerId?.includes('github')) {
|
||||
return 'pi pi-github'
|
||||
}
|
||||
return 'pi pi-user'
|
||||
})
|
||||
|
||||
const isEmailProvider = computed(() => {
|
||||
const providerId = user.value?.providerData[0]?.providerId
|
||||
return providerId === 'password'
|
||||
})
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await commandStore.execute('Comfy.User.SignOut')
|
||||
}
|
||||
|
||||
const handleSignIn = async () => {
|
||||
await commandStore.execute('Comfy.User.OpenSignInDialog')
|
||||
}
|
||||
const {
|
||||
loading,
|
||||
isLoggedIn,
|
||||
isEmailProvider,
|
||||
userDisplayName,
|
||||
userEmail,
|
||||
userPhotoUrl,
|
||||
providerName,
|
||||
providerIcon,
|
||||
handleSignOut,
|
||||
handleSignIn
|
||||
} = useCurrentUser()
|
||||
</script>
|
||||
|
||||
114
src/components/dialog/content/signin/ApiKeyForm.test.ts
Normal file
114
src/components/dialog/content/signin/ApiKeyForm.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ApiKeyForm from './ApiKeyForm.vue'
|
||||
|
||||
const mockStoreApiKey = vi.fn()
|
||||
const mockLoading = vi.fn(() => false)
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
loading: mockLoading()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: vi.fn(() => ({
|
||||
storeApiKey: mockStoreApiKey
|
||||
}))
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
auth: {
|
||||
apiKey: {
|
||||
title: 'API Key',
|
||||
label: 'API Key',
|
||||
placeholder: 'Enter your API Key',
|
||||
error: 'Invalid API Key',
|
||||
helpText: 'Need an API key?',
|
||||
generateKey: 'Get one here',
|
||||
whitelistInfo: 'About non-whitelisted sites'
|
||||
}
|
||||
},
|
||||
g: {
|
||||
back: 'Back',
|
||||
save: 'Save',
|
||||
learnMore: 'Learn more'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('ApiKeyForm', () => {
|
||||
beforeEach(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
vi.clearAllMocks()
|
||||
mockStoreApiKey.mockReset()
|
||||
mockLoading.mockReset()
|
||||
})
|
||||
|
||||
const mountComponent = (props: any = {}) => {
|
||||
return mount(ApiKeyForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
components: { Button, Form, InputText, Message }
|
||||
},
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
it('renders correctly with all required elements', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('API Key')
|
||||
expect(wrapper.find('label').text()).toBe('API Key')
|
||||
expect(wrapper.findComponent(InputText).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits back event when back button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await wrapper.findComponent(Button).trigger('click')
|
||||
expect(wrapper.emitted('back')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows loading state when submitting', async () => {
|
||||
mockLoading.mockReturnValue(true)
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.findComponent(InputText)
|
||||
|
||||
await input.setValue(
|
||||
'comfyui-123456789012345678901234567890123456789012345678901234567890123456789012'
|
||||
)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
const submitButton = wrapper
|
||||
.findAllComponents(Button)
|
||||
.find((btn) => btn.text() === 'Save')
|
||||
expect(submitButton?.props('loading')).toBe(true)
|
||||
})
|
||||
|
||||
it('displays help text and links correctly', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const helpText = wrapper.find('small')
|
||||
expect(helpText.text()).toContain('Need an API key?')
|
||||
expect(helpText.find('a').attributes('href')).toBe(
|
||||
'https://platform.comfy.org/login'
|
||||
)
|
||||
})
|
||||
})
|
||||
111
src/components/dialog/content/signin/ApiKeyForm.vue
Normal file
111
src/components/dialog/content/signin/ApiKeyForm.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<h1 class="text-2xl font-medium leading-normal my-0">
|
||||
{{ t('auth.apiKey.title') }}
|
||||
</h1>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base my-0 text-muted">
|
||||
{{ t('auth.apiKey.description') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.comfy.org/interface/user#logging-in-with-an-api-key"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('g.learnMore') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-slot="$form"
|
||||
class="flex flex-col gap-6"
|
||||
:resolver="zodResolver(apiKeySchema)"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<Message v-if="$form.apiKey?.invalid" severity="error" class="mb-4">
|
||||
{{ $form.apiKey.error.message }}
|
||||
</Message>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
class="opacity-80 text-base font-medium mb-2"
|
||||
for="comfy-org-api-key"
|
||||
>
|
||||
{{ t('auth.apiKey.label') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<InputText
|
||||
pt:root:id="comfy-org-api-key"
|
||||
pt:root:autocomplete="off"
|
||||
class="h-10"
|
||||
name="apiKey"
|
||||
type="password"
|
||||
:placeholder="t('auth.apiKey.placeholder')"
|
||||
:invalid="$form.apiKey?.invalid"
|
||||
/>
|
||||
<small class="text-muted">
|
||||
{{ t('auth.apiKey.helpText') }}
|
||||
<a
|
||||
href="https://platform.comfy.org/login"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.apiKey.generateKey') }}
|
||||
</a>
|
||||
<span class="mx-1">•</span>
|
||||
<a
|
||||
href="https://docs.comfy.org/tutorials/api-nodes/overview#log-in-with-api-key-on-non-whitelisted-websites"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.apiKey.whitelistInfo') }}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<Button type="button" link @click="$emit('back')">
|
||||
{{ t('g.back') }}
|
||||
</Button>
|
||||
<Button type="submit" :loading="loading" :disabled="loading">
|
||||
{{ t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormSubmitEvent } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { apiKeySchema } from '@/schemas/signInSchema'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const apiKeyStore = useApiKeyAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'back'): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const onSubmit = async (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
await apiKeyStore.storeApiKey(event.values.apiKey)
|
||||
emit('success')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,11 @@
|
||||
<!-- A dialog header with ComfyOrg logo -->
|
||||
<template>
|
||||
<div class="px-2 py-4">
|
||||
<img src="/assets/images/Comfy_Logo_x32.png" alt="ComfyOrg Logo" />
|
||||
<img
|
||||
src="/assets/images/comfy-logo-single.svg"
|
||||
alt="ComfyOrg Logo"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<template #content>
|
||||
<div class="flex items-center px-4 py-3">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<h3 class="line-clamp-2 text-lg font-normal mb-0 h-10" :title="title">
|
||||
<h3 class="line-clamp-2 text-lg font-normal mb-0 h-12" :title="title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
|
||||
@@ -79,15 +79,13 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
|
||||
const DEFAULT_ZOOM_SCALE = 5
|
||||
|
||||
const { sourceModule, categoryTitle, loading, template } = defineProps<{
|
||||
const { sourceModule, loading, template } = defineProps<{
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: boolean
|
||||
@@ -116,17 +114,17 @@ const overlayThumbnailSrc = computed(() =>
|
||||
getThumbnailUrl(sourceModule === 'default' ? '2' : '')
|
||||
)
|
||||
|
||||
const title = computed(() => {
|
||||
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
|
||||
const description = computed(() => {
|
||||
return sourceModule === 'default'
|
||||
? st(
|
||||
`templateWorkflows.template.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`,
|
||||
fallback
|
||||
)
|
||||
: fallback
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description.replace(/[-_]/g, ' ').trim()
|
||||
})
|
||||
|
||||
const description = computed(() => template.description.replace(/[-_]/g, ' '))
|
||||
const title = computed(() => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? ''
|
||||
: template.name
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
|
||||
70
src/components/templates/TemplateWorkflowList.vue
Normal file
70
src/components/templates/TemplateWorkflowList.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<DataTable
|
||||
v-model:selection="selectedTemplate"
|
||||
:value="templates"
|
||||
striped-rows
|
||||
selection-mode="single"
|
||||
>
|
||||
<Column field="title" :header="$t('g.title')">
|
||||
<template #body="slotProps">
|
||||
<span :title="getTemplateTitle(slotProps.data)">{{
|
||||
getTemplateTitle(slotProps.data)
|
||||
}}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="description" :header="$t('g.description')">
|
||||
<template #body="slotProps">
|
||||
<span :title="getTemplateDescription(slotProps.data)">
|
||||
{{ getTemplateDescription(slotProps.data) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="actions" header="" class="w-12">
|
||||
<template #body="slotProps">
|
||||
<Button
|
||||
icon="pi pi-arrow-right"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
:loading="loading === slotProps.data.name"
|
||||
@click="emit('loadWorkflow', slotProps.data.name)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const { sourceModule, loading, templates } = defineProps<{
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: string | null
|
||||
templates: TemplateInfo[]
|
||||
}>()
|
||||
|
||||
const selectedTemplate = ref(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
const getTemplateTitle = (template: TemplateInfo) => {
|
||||
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
const getTemplateDescription = (template: TemplateInfo) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description.replace(/[-_]/g, ' ').trim()
|
||||
}
|
||||
</script>
|
||||
82
src/components/templates/TemplateWorkflowView.vue
Normal file
82
src/components/templates/TemplateWorkflowView.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<DataView
|
||||
:value="templates"
|
||||
:layout="layout"
|
||||
data-key="name"
|
||||
:lazy="true"
|
||||
pt:root="h-full grid grid-rows-[auto_1fr]"
|
||||
pt:content="p-2 overflow-auto"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg">{{ title }}</h2>
|
||||
<SelectButton
|
||||
v-model="layout"
|
||||
:options="['grid', 'list']"
|
||||
:allow-empty="false"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #list="{ items }">
|
||||
<TemplateWorkflowList
|
||||
:source-module="sourceModule"
|
||||
:templates="items"
|
||||
:loading="loading"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #grid="{ items }">
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] auto-rows-fr gap-8 justify-items-center"
|
||||
>
|
||||
<TemplateWorkflowCard
|
||||
v-for="template in items"
|
||||
:key="template.name"
|
||||
:source-module="sourceModule"
|
||||
:template="template"
|
||||
:loading="loading === template.name"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DataView>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import DataView from 'primevue/dataview'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
|
||||
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
|
||||
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
loading: string | null
|
||||
templates: TemplateInfo[]
|
||||
}>()
|
||||
|
||||
const layout = useLocalStorage<'grid' | 'list'>(
|
||||
'Comfy.TemplateWorkflow.Layout',
|
||||
'grid'
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
const onLoadWorkflow = (name: string) => {
|
||||
emit('loadWorkflow', name)
|
||||
}
|
||||
</script>
|
||||
@@ -30,36 +30,22 @@
|
||||
/>
|
||||
</aside>
|
||||
<div
|
||||
class="flex-1 overflow-auto transition-all duration-300"
|
||||
class="flex-1 transition-all duration-300"
|
||||
:class="{
|
||||
'pl-80': isSideNavOpen || !isSmallScreen,
|
||||
'pl-8': !isSideNavOpen && isSmallScreen
|
||||
}"
|
||||
>
|
||||
<div v-if="isReady && selectedTab" class="flex flex-col px-12 pb-4">
|
||||
<div class="py-3 text-left">
|
||||
<h2 class="text-lg">
|
||||
{{ selectedTab.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] auto-rows-fr gap-8 justify-items-center"
|
||||
>
|
||||
<div
|
||||
v-for="template in selectedTab.templates"
|
||||
:key="template.name"
|
||||
class="h-full"
|
||||
>
|
||||
<TemplateWorkflowCard
|
||||
:source-module="selectedTab.moduleName"
|
||||
:template="template"
|
||||
:loading="template.name === workflowLoading"
|
||||
:category-title="selectedTab.title"
|
||||
@load-workflow="loadWorkflow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TemplateWorkflowView
|
||||
v-if="isReady && selectedTab"
|
||||
class="px-12 py-4"
|
||||
:title="selectedTab.title"
|
||||
:source-module="selectedTab.moduleName"
|
||||
:templates="selectedTab.templates"
|
||||
:loading="workflowLoading"
|
||||
:category-title="selectedTab.title"
|
||||
@load-workflow="loadWorkflow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +59,7 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
|
||||
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:model-value="selectedTab"
|
||||
:options="tabs"
|
||||
option-group-label="label"
|
||||
option-label="title"
|
||||
option-label="localizedTitle"
|
||||
option-group-children="modules"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<Button
|
||||
v-if="isAuthenticated"
|
||||
v-if="isLoggedIn"
|
||||
class="user-profile-button p-1"
|
||||
severity="secondary"
|
||||
text
|
||||
@@ -12,12 +12,7 @@
|
||||
<div
|
||||
class="flex items-center rounded-full bg-[var(--p-content-background)]"
|
||||
>
|
||||
<Avatar
|
||||
:image="photoURL"
|
||||
:icon="photoURL ? undefined : 'pi pi-user'"
|
||||
shape="circle"
|
||||
aria-label="User Avatar"
|
||||
/>
|
||||
<UserAvatar :photo-url="photoURL" />
|
||||
|
||||
<i class="pi pi-chevron-down px-1" :style="{ fontSize: '0.5rem' }" />
|
||||
</div>
|
||||
@@ -30,20 +25,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Avatar from 'primevue/avatar'
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const photoURL = computed<string | undefined>(
|
||||
() => authStore.currentUser?.photoURL ?? undefined
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
<!-- User Info Section -->
|
||||
<div class="p-3">
|
||||
<div class="flex flex-col items-center">
|
||||
<Avatar
|
||||
<UserAvatar
|
||||
class="mb-3"
|
||||
:image="user?.photoURL ?? undefined"
|
||||
:icon="user?.photoURL ? undefined : 'pi pi-user !text-2xl'"
|
||||
shape="circle"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{
|
||||
'!text-2xl': !userPhotoUrl
|
||||
}"
|
||||
size="large"
|
||||
aria-label="User Avatar"
|
||||
/>
|
||||
|
||||
<!-- User Details -->
|
||||
<h3 class="text-lg font-semibold truncate my-0 mb-1">
|
||||
{{ user?.displayName || $t('g.user') }}
|
||||
{{ userDisplayName || $t('g.user') }}
|
||||
</h3>
|
||||
<p v-if="user?.email" class="text-sm text-muted truncate my-0">
|
||||
{{ user.email }}
|
||||
<p v-if="userEmail" class="text-sm text-muted truncate my-0">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,6 +37,18 @@
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('credits.apiPricing')"
|
||||
icon="pi pi-external-link"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
@click="handleOpenApiPricing"
|
||||
/>
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<div class="w-full flex flex-col gap-2 p-2">
|
||||
<div class="text-muted text-sm">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
@@ -50,22 +62,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Avatar from 'primevue/avatar'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
|
||||
const authService = useFirebaseAuthService()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const user = computed(() => authStore.currentUser)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
}
|
||||
@@ -74,6 +84,10 @@ const handleTopUp = () => {
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
const handleOpenApiPricing = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authService.fetchBalance()
|
||||
})
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
class="comfyui-menu flex items-center"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<h1 class="comfyui-logo mx-2 app-drag">ComfyUI</h1>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo ml-2 app-drag h-6"
|
||||
/>
|
||||
<CommandMenubar />
|
||||
<div class="flex-grow min-w-0 app-drag h-full">
|
||||
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
|
||||
@@ -126,8 +130,12 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.comfyui-logo {
|
||||
font-size: 1.2em;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
filter: invert(0);
|
||||
}
|
||||
|
||||
.dark-theme .comfyui-logo {
|
||||
filter: invert(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user