Merge branch 'main' of https://github.com/Comfy-Org/ComfyUI_frontend into mobile-dvh

This commit is contained in:
Benjamin Lu
2025-04-16 09:09:40 -04:00
39 changed files with 3159 additions and 170 deletions

View File

@@ -8,6 +8,15 @@ const vue3CompositionApiBestPractices = [
"Use watch and watchEffect for side effects",
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
"Utilize provide/inject for dependency injection",
"Use vue 3.5 style of default prop declaration. Example:
const { nodes, showTotal = true } = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
}>()
",
"Organize vue component in <template> <script> <style> order",
]
// Folder structure
@@ -40,4 +49,6 @@ const additionalInstructions = `
7. Implement proper error handling
8. Follow Vue 3 style guide and naming conventions
9. Use Vite for fast development and building
10. Use vue-i18n in composition API for any string literals. Place new translation
entries in src/locales/en/main.json.
`;

25
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,25 @@
{
"recommendations": [
"austenc.tailwind-docs",
"bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"eamodio.gitlens",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"github.vscode-github-actions",
"github.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"lokalise.i18n-ally",
"ms-playwright.playwright",
"vitest.explorer",
"vue.volar",
"sonarsource.sonarlint-vscode",
"deque-systems.vscode-axe-linter",
"kisstkondoros.vscode-codemetrics",
"donjayamanne.githistory",
"wix.vscode-import-cost",
"prograhammer.tslint-vue",
"antfu.vite"
]
}

1170
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.16.7",
"version": "1.17.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -73,7 +73,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.13.1",
"@comfyorg/litegraph": "^0.13.3",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -91,6 +91,7 @@
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"dotenv": "^16.4.5",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
@@ -103,6 +104,7 @@
"vue": "^3.5.13",
"vue-i18n": "^9.14.3",
"vue-router": "^4.4.3",
"vuefire": "^3.2.1",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,75 @@
<template>
<div class="flex flex-col gap-3 h-full">
<div class="flex justify-between text-xs">
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
<div>{{ t('apiNodesCostBreakdown.costPerRun') }}</div>
</div>
<ScrollPanel class="flex-grow h-0">
<div class="flex flex-col gap-2">
<div
v-for="node in nodes"
:key="node.name"
class="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--p-content-border-color)]"
>
<div class="flex items-center gap-2">
<span class="text-base font-medium leading-tight">{{
node.name
}}</span>
</div>
<div class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<span class="text-base font-medium leading-tight">
{{ node.cost.toFixed(costPrecision) }}
</span>
</div>
</div>
</div>
</ScrollPanel>
<template v-if="showTotal && nodes.length > 1">
<Divider class="my-2" />
<div class="flex justify-between items-center border-t px-3">
<span class="text-sm">{{ t('apiNodesCostBreakdown.totalCost') }}</span>
<div class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-yellow-500 p-1"
/>
<span>{{ totalCost.toFixed(costPrecision) }}</span>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ApiNodeCost } from '@/types/apiNodeTypes'
const { t } = useI18n()
const {
nodes,
showTotal = true,
costPrecision = 3
} = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
costPrecision?: number
}>()
const totalCost = computed(() =>
nodes.reduce((sum, node) => sum + node.cost, 0)
)
</script>

View File

@@ -0,0 +1,43 @@
<!-- Prompt user that the workflow contains API nodes that needs login to run -->
<template>
<div class="flex flex-col gap-4 max-w-96 h-110 p-2">
<div class="text-2xl font-medium mb-2">
{{ t('apiNodesSignInDialog.title') }}
</div>
<div class="text-base mb-4">
{{ t('apiNodesSignInDialog.message') }}
</div>
<ApiNodesCostBreakdown :nodes="apiNodes" :show-total="true" />
<div class="flex justify-between items-center">
<Button :label="t('g.learnMore')" link />
<div class="flex gap-2">
<Button
:label="t('g.cancel')"
outlined
severity="secondary"
@click="onCancel?.()"
/>
<Button :label="t('g.login')" @click="onLogin?.()" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import ApiNodesCostBreakdown from '@/components/common/ApiNodesCostBreakdown.vue'
import type { ApiNodeCost } from '@/types/apiNodeTypes'
const { t } = useI18n()
const { apiNodes, onLogin, onCancel } = defineProps<{
apiNodes: ApiNodeCost[]
onLogin?: () => void
onCancel?: () => void
}>()
</script>

View File

@@ -0,0 +1,124 @@
<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>
<!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
<SignUpForm v-else @submit="signInWithEmail" />
<!-- 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 -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<span class="text-blue-500 cursor-pointer">{{
t('auth.login.termsLink')
}}</span>
{{ t('auth.login.andText') }}
<span class="text-blue-500 cursor-pointer">{{
t('auth.login.privacyLink')
}}</span
>.
</p>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import SignInForm from './signin/SignInForm.vue'
import SignUpForm from './signin/SignUpForm.vue'
const { t } = useI18n()
const { onSuccess } = defineProps<{
onSuccess: () => void
}>()
const firebaseAuthStore = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isSignIn = ref(true)
const toggleState = () => {
isSignIn.value = !isSignIn.value
}
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
await firebaseAuthStore.loginWithGoogle()
onSuccess()
})
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
await firebaseAuthStore.loginWithGithub()
onSuccess()
})
const signInWithEmail = wrapWithErrorHandlingAsync(
async (values: SignInData | SignUpData) => {
const { email, password } = values
if (isSignIn.value) {
await firebaseAuthStore.login(email, password)
} else {
await firebaseAuthStore.register(email, password)
}
onSuccess()
}
)
</script>

View File

@@ -101,7 +101,6 @@
<script setup lang="ts">
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
// @ts-expect-error https://github.com/primefaces/primevue/issues/6722
import { zodResolver } from '@primevue/forms/resolvers/zod'
import type { CaptureContext, User } from '@sentry/core'
import { captureMessage } from '@sentry/core'

View File

@@ -0,0 +1,89 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signInSchema)"
@submit="onSubmit"
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-in-email"
>
{{ t('auth.login.emailLabel') }}
</label>
<InputText
pt:root:id="comfy-org-sign-in-email"
pt:root:autocomplete="email"
class="h-10"
name="email"
type="text"
:placeholder="t('auth.login.emailPlaceholder')"
:invalid="$form.email?.invalid"
/>
<small v-if="$form.email?.invalid" class="text-red-500">{{
$form.email.error.message
}}</small>
</div>
<!-- Password Field -->
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center mb-2">
<label
class="opacity-80 text-base font-medium"
for="comfy-org-sign-in-password"
>
{{ t('auth.login.passwordLabel') }}
</label>
<span class="text-muted text-base font-medium cursor-pointer">
{{ t('auth.login.forgotPassword') }}
</span>
</div>
<Password
input-id="comfy-org-sign-in-password"
pt:pc-input-text:root:autocomplete="current-password"
name="password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.passwordPlaceholder')"
:class="{ 'p-invalid': $form.password?.invalid }"
fluid
class="h-10"
/>
<small v-if="$form.password?.invalid" class="text-red-500">{{
$form.password.error.message
}}</small>
</div>
<!-- Submit Button -->
<Button
type="submit"
:label="t('auth.login.loginButton')"
class="h-10 font-medium mt-4"
/>
</Form>
</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 Password from 'primevue/password'
import { useI18n } from 'vue-i18n'
import { type SignInData, signInSchema } from '@/schemas/signInSchema'
const { t } = useI18n()
const emit = defineEmits<{
submit: [values: SignInData]
}>()
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
</script>

View File

@@ -0,0 +1,165 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signUpSchema)"
@submit="onSubmit"
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-up-email"
>
{{ t('auth.signup.emailLabel') }}
</label>
<InputText
pt:root:id="comfy-org-sign-up-email"
pt:root:autocomplete="email"
class="h-10"
name="email"
type="text"
:placeholder="t('auth.signup.emailPlaceholder')"
:invalid="$form.email?.invalid"
/>
<small v-if="$form.email?.invalid" class="text-red-500">{{
$form.email.error.message
}}</small>
</div>
<!-- Password Field -->
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center mb-2">
<label
class="opacity-80 text-base font-medium"
for="comfy-org-sign-up-password"
>
{{ t('auth.signup.passwordLabel') }}
</label>
</div>
<Password
v-model="password"
input-id="comfy-org-sign-up-password"
pt:pc-input-text:root:autocomplete="new-password"
name="password"
:feedback="false"
toggle-mask
:placeholder="t('auth.signup.passwordPlaceholder')"
:class="{ 'p-invalid': $form.password?.invalid }"
fluid
class="h-10"
/>
<div class="flex flex-col gap-1">
<small
v-if="$form.password?.dirty || $form.password?.invalid"
class="text-sm"
>
{{ t('validation.password.requirements') }}:
<ul class="mt-1 space-y-1">
<li
:class="{
'text-red-500': !passwordChecks.length
}"
>
{{ t('validation.password.minLength') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.uppercase
}"
>
{{ t('validation.password.uppercase') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.lowercase
}"
>
{{ t('validation.password.lowercase') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.number
}"
>
{{ t('validation.password.number') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.special
}"
>
{{ t('validation.password.special') }}
</li>
</ul>
</small>
</div>
</div>
<!-- Confirm Password Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-up-confirm-password"
>
{{ t('auth.login.confirmPasswordLabel') }}
</label>
<Password
name="confirmPassword"
input-id="comfy-org-sign-up-confirm-password"
pt:pc-input-text:root:autocomplete="new-password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.confirmPasswordPlaceholder')"
:class="{ 'p-invalid': $form.confirmPassword?.invalid }"
fluid
class="h-10"
/>
<small v-if="$form.confirmPassword?.error" class="text-red-500">{{
$form.confirmPassword.error.message
}}</small>
</div>
<!-- Submit Button -->
<Button
type="submit"
:label="t('auth.signup.signUpButton')"
class="h-10 font-medium mt-4"
/>
</Form>
</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 Password from 'primevue/password'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { type SignUpData, signUpSchema } from '@/schemas/signInSchema'
const { t } = useI18n()
const password = ref('')
// TODO: Use dynamic form to better organize the password checks.
// Ref: https://primevue.org/forms/#dynamic
const passwordChecks = computed(() => ({
length: password.value.length >= 8 && password.value.length <= 32,
uppercase: /[A-Z]/.test(password.value),
lowercase: /[a-z]/.test(password.value),
number: /\d/.test(password.value),
special: /[^A-Za-z0-9]/.test(password.value)
}))
const emit = defineEmits<{
submit: [values: SignUpData]
}>()
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignUpData)
}
}
</script>

View File

@@ -0,0 +1,6 @@
<!-- A dialog header with ComfyOrg logo -->
<template>
<div class="px-2 py-4">
<img src="/assets/images/Comfy_Logo_x32.png" alt="ComfyOrg Logo" />
</div>
</template>

12
src/config/firebase.ts Normal file
View File

@@ -0,0 +1,12 @@
import { FirebaseOptions } from 'firebase/app'
export const FIREBASE_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyC2-fomLqgCjb7ELwta1I9cEarPK8ziTGs',
authDomain: 'dreamboothy.firebaseapp.com',
databaseURL: 'https://dreamboothy-default-rtdb.firebaseio.com',
projectId: 'dreamboothy',
storageBucket: 'dreamboothy.appspot.com',
messagingSenderId: '357148958219',
appId: '1:357148958219:web:f5917f72e5f36a2015310e',
measurementId: 'G-3ZBD3MBTG4'
}

View File

@@ -18,7 +18,7 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
shortDescription: 'Change the application base path.',
errorDescription: 'Unable to open the base path. Please select a new one.',
description:
'The base path is the default location where ComfyUI stores data. It is the location fo the python environment, and may also contain models, custom nodes, and other extensions.',
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true,
button: {
icon: PrimeIcons.QUESTION,

View File

@@ -18,7 +18,39 @@ import { generateUUID } from '@/utils/formatUtil'
useExtensionService().registerExtension({
name: 'Comfy.Load3D',
settings: [
{
id: 'Comfy.Load3D.ShowGrid',
category: ['3D', 'Scene', 'Initial Grid Visibility'],
name: 'Initial Grid Visibility',
tooltip:
'Controls whether the grid is visible by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation.',
type: 'boolean',
defaultValue: true,
experimental: true
},
{
id: 'Comfy.Load3D.ShowPreview',
category: ['3D', 'Scene', 'Initial Preview Visibility'],
name: 'Initial Preview Visibility',
tooltip:
'Controls whether the preview screen is visible by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation.',
type: 'boolean',
defaultValue: true,
experimental: true
},
{
id: 'Comfy.Load3D.CameraType',
category: ['3D', 'Camera', 'Initial Camera Type'],
name: 'Initial Camera Type',
tooltip:
'Controls whether the camera is perspective or orthographic by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation.',
type: 'combo',
options: ['perspective', 'orthographic'],
defaultValue: 'perspective',
experimental: true
}
],
getCustomWidgets() {
return {
LOAD_3D(node) {

View File

@@ -3,6 +3,7 @@ import type { IWidget } from '@comfyorg/litegraph'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { api } from '@/scripts/api'
import { useSettingStore } from '@/stores/settingStore'
class Load3DConfiguration {
constructor(private load3d: Load3d) {}
@@ -72,15 +73,21 @@ class Load3DConfiguration {
private setupDefaultProperties() {
const cameraType = this.load3d.loadNodeProperty(
'Camera Type',
'perspective'
useSettingStore().get('Comfy.Load3D.CameraType')
)
this.load3d.toggleCamera(cameraType)
const showGrid = this.load3d.loadNodeProperty('Show Grid', true)
const showGrid = this.load3d.loadNodeProperty(
'Show Grid',
useSettingStore().get('Comfy.Load3D.ShowGrid')
)
this.load3d.toggleGrid(showGrid)
const showPreview = this.load3d.loadNodeProperty('Show Preview', true)
const showPreview = this.load3d.loadNodeProperty(
'Show Preview',
useSettingStore().get('Comfy.Load3D.ShowPreview')
)
this.load3d.togglePreview(showPreview)

View File

@@ -108,7 +108,9 @@
"disabling": "Disabling",
"updating": "Updating",
"migrate": "Migrate",
"updateAvailable": "Update Available"
"updateAvailable": "Update Available",
"login": "Login",
"learnMore": "Learn more"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -716,7 +718,11 @@
"CustomColorPalettes": "Custom Color Palettes",
"UV": "UV",
"ContextMenu": "Context Menu",
"Reroute": "Reroute"
"Reroute": "Reroute",
"Load 3D": "Load 3D",
"Camera": "Camera",
"Scene": "Scene",
"3D": "3D"
},
"serverConfigItems": {
"listen": {
@@ -972,6 +978,15 @@
"extensionFileHint": "This may be due to the following script",
"promptExecutionError": "Prompt execution failed"
},
"apiNodesSignInDialog": {
"title": "Sign In Required to Use API Nodes",
"message": "This workflow contains API Nodes, which require you to be signed in to your account in order to run."
},
"apiNodesCostBreakdown": {
"title": "API Node(s)",
"costPerRun": "Cost per run",
"totalCost": "Total Cost"
},
"desktopUpdate": {
"title": "Updating ComfyUI Desktop",
"description": "ComfyUI Desktop is installing new dependencies. This may take a few minutes.",
@@ -1036,5 +1051,56 @@
"noTemplatesToExport": "No templates to export",
"failedToFetchLogs": "Failed to fetch server logs",
"migrateToLitegraphReroute": "Reroute nodes will be removed in future versions. Click to migrate to litegraph-native reroute."
},
"auth": {
"login": {
"title": "Log in to your account",
"newUser": "New here?",
"signUp": "Sign up",
"emailLabel": "Email",
"emailPlaceholder": "Enter your email",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter your password",
"confirmPasswordLabel": "Confirm Password",
"confirmPasswordPlaceholder": "Enter the same password again",
"forgotPassword": "Forgot password?",
"loginButton": "Log in",
"orContinueWith": "Or continue with",
"loginWithGoogle": "Log in with Google",
"loginWithGithub": "Log in with Github",
"termsText": "By clicking \"Next\" or \"Sign Up\", you agree to our",
"termsLink": "Terms of Use",
"andText": "and",
"privacyLink": "Privacy Policy",
"success": "Login successful",
"failed": "Login failed"
},
"signup": {
"title": "Create an account",
"alreadyHaveAccount": "Already have an account?",
"emailLabel": "Email",
"emailPlaceholder": "Enter your email",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter new password",
"signUpButton": "Sign up",
"signIn": "Sign in",
"signUpWithGoogle": "Sign up with Google",
"signUpWithGithub": "Sign up with Github"
}
},
"validation": {
"invalidEmail": "Invalid email address",
"required": "Required",
"minLength": "Must be at least {length} characters",
"maxLength": "Must be no more than {length} characters",
"password": {
"requirements": "Password requirements",
"minLength": "Must be between 8 and 32 characters",
"uppercase": "Must contain at least one uppercase letter",
"lowercase": "Must contain at least one lowercase letter",
"number": "Must contain at least one number",
"special": "Must contain at least one special character",
"match": "Passwords must match"
}
}
}

View File

@@ -108,6 +108,22 @@
"Hidden": "Hidden"
}
},
"Comfy_Load3D_CameraType": {
"name": "Initial Camera Type",
"tooltip": "Controls whether the camera is perspective or orthographic by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation.",
"options": {
"perspective": "perspective",
"orthographic": "orthographic"
}
},
"Comfy_Load3D_ShowGrid": {
"name": "Initial Grid Visibility",
"tooltip": "Controls whether the grid is visible by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation."
},
"Comfy_Load3D_ShowPreview": {
"name": "Initial Preview Visibility",
"tooltip": "Controls whether the preview screen is visible by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation."
},
"Comfy_Locale": {
"name": "Language"
},

View File

@@ -1,4 +1,49 @@
{
"apiNodesCostBreakdown": {
"costPerRun": "Costo por ejecución",
"title": "Nodo(s) de API",
"totalCost": "Costo total"
},
"apiNodesSignInDialog": {
"message": "Este flujo de trabajo contiene nodos de API, que requieren que inicies sesión en tu cuenta para poder ejecutar.",
"title": "Se requiere iniciar sesión para usar los nodos de API"
},
"auth": {
"login": {
"andText": "y",
"confirmPasswordLabel": "Confirmar contraseña",
"confirmPasswordPlaceholder": "Ingresa la misma contraseña nuevamente",
"emailLabel": "Correo electrónico",
"emailPlaceholder": "Ingresa tu correo electrónico",
"failed": "Inicio de sesión fallido",
"forgotPassword": "¿Olvidaste tu contraseña?",
"loginButton": "Iniciar sesión",
"loginWithGithub": "Iniciar sesión con Github",
"loginWithGoogle": "Iniciar sesión con Google",
"newUser": "¿Eres nuevo aquí?",
"orContinueWith": "O continuar con",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresa tu contraseña",
"privacyLink": "Política de privacidad",
"signUp": "Regístrate",
"success": "Inicio de sesión exitoso",
"termsLink": "Términos de uso",
"termsText": "Al hacer clic en \"Siguiente\" o \"Registrarse\", aceptas nuestros",
"title": "Inicia sesión en tu cuenta"
},
"signup": {
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"emailLabel": "Correo electrónico",
"emailPlaceholder": "Ingresa tu correo electrónico",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresa una nueva contraseña",
"signIn": "Iniciar sesión",
"signUpButton": "Registrarse",
"signUpWithGithub": "Registrarse con Github",
"signUpWithGoogle": "Registrarse con Google",
"title": "Crea una cuenta"
}
},
"clipboard": {
"errorMessage": "Error al copiar al portapapeles",
"errorNotSupported": "API del portapapeles no soportada en su navegador",
@@ -172,9 +217,11 @@
"installing": "Instalando",
"interrupted": "Interrumpido",
"keybinding": "Combinación de teclas",
"learnMore": "Aprende más",
"loadAllFolders": "Cargar todas las carpetas",
"loadWorkflow": "Cargar flujo de trabajo",
"loading": "Cargando",
"login": "Iniciar sesión",
"logs": "Registros",
"migrate": "Migrar",
"missing": "Faltante",
@@ -812,9 +859,11 @@
"troubleshoot": "Solucionar problemas"
},
"settingsCategories": {
"3D": "3D",
"About": "Acerca de",
"Appearance": "Apariencia",
"BrushAdjustment": "Ajuste de Pincel",
"Camera": "Cámara",
"Canvas": "Lienzo",
"ColorPalette": "Paleta de Colores",
"Comfy": "Comfy",
@@ -831,6 +880,7 @@
"Link": "Enlace",
"LinkRelease": "Liberación de Enlace",
"LiteGraph": "Lite Graph",
"Load 3D": "Cargar 3D",
"Locale": "Localización",
"Mask Editor": "Editor de Máscara",
"Menu": "Menú",
@@ -845,6 +895,7 @@
"QueueButton": "Botón de Cola",
"Reroute": "Reenrutar",
"RerouteBeta": "Reroute Beta",
"Scene": "Escena",
"Server": "Servidor",
"Server-Config": "Configuración del Servidor",
"Settings": "Configuraciones",
@@ -1028,6 +1079,21 @@
"next": "Siguiente",
"selectUser": "Selecciona un usuario"
},
"validation": {
"invalidEmail": "Dirección de correo electrónico inválida",
"maxLength": "No debe tener más de {length} caracteres",
"minLength": "Debe tener al menos {length} caracteres",
"password": {
"lowercase": "Debe contener al menos una letra minúscula",
"match": "Las contraseñas deben coincidir",
"minLength": "Debe tener entre 8 y 32 caracteres",
"number": "Debe contener al menos un número",
"requirements": "Requisitos de la contraseña",
"special": "Debe contener al menos un carácter especial",
"uppercase": "Debe contener al menos una letra mayúscula"
},
"required": "Requerido"
},
"welcome": {
"getStarted": "Empezar",
"title": "Bienvenido a ComfyUI"

View File

@@ -108,6 +108,22 @@
"Straight": "Recto"
}
},
"Comfy_Load3D_CameraType": {
"name": "Tipo de Cámara",
"options": {
"orthographic": "ortográfica",
"perspective": "perspectiva"
},
"tooltip": "Controla si la cámara es perspectiva u ortográfica por defecto cuando se crea un nuevo widget 3D. Este valor predeterminado aún puede ser alternado individualmente para cada widget después de su creación."
},
"Comfy_Load3D_ShowGrid": {
"name": "Mostrar Cuadrícula",
"tooltip": "Cambiar para mostrar cuadrícula por defecto"
},
"Comfy_Load3D_ShowPreview": {
"name": "Mostrar Previsualización",
"tooltip": "Cambiar para mostrar previsualización por defecto"
},
"Comfy_Locale": {
"name": "Idioma"
},

View File

@@ -1,4 +1,49 @@
{
"apiNodesCostBreakdown": {
"costPerRun": "Coût par exécution",
"title": "Nœud(s) API",
"totalCost": "Coût total"
},
"apiNodesSignInDialog": {
"message": "Ce flux de travail contient des nœuds API, qui nécessitent que vous soyez connecté à votre compte pour pouvoir fonctionner.",
"title": "Connexion requise pour utiliser les nœuds API"
},
"auth": {
"login": {
"andText": "et",
"confirmPasswordLabel": "Confirmer le mot de passe",
"confirmPasswordPlaceholder": "Entrez à nouveau le même mot de passe",
"emailLabel": "Email",
"emailPlaceholder": "Entrez votre email",
"failed": "Échec de la connexion",
"forgotPassword": "Mot de passe oublié?",
"loginButton": "Se connecter",
"loginWithGithub": "Se connecter avec Github",
"loginWithGoogle": "Se connecter avec Google",
"newUser": "Nouveau ici?",
"orContinueWith": "Ou continuer avec",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrez votre mot de passe",
"privacyLink": "Politique de confidentialité",
"signUp": "S'inscrire",
"success": "Connexion réussie",
"termsLink": "Conditions d'utilisation",
"termsText": "En cliquant sur \"Suivant\" ou \"S'inscrire\", vous acceptez nos",
"title": "Connectez-vous à votre compte"
},
"signup": {
"alreadyHaveAccount": "Vous avez déjà un compte?",
"emailLabel": "Email",
"emailPlaceholder": "Entrez votre email",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrez un nouveau mot de passe",
"signIn": "Se connecter",
"signUpButton": "S'inscrire",
"signUpWithGithub": "S'inscrire avec Github",
"signUpWithGoogle": "S'inscrire avec Google",
"title": "Créer un compte"
}
},
"clipboard": {
"errorMessage": "Échec de la copie dans le presse-papiers",
"errorNotSupported": "L'API du presse-papiers n'est pas prise en charge par votre navigateur",
@@ -172,9 +217,11 @@
"installing": "Installation",
"interrupted": "Interrompu",
"keybinding": "Raccourci clavier",
"learnMore": "En savoir plus",
"loadAllFolders": "Charger tous les dossiers",
"loadWorkflow": "Charger le flux de travail",
"loading": "Chargement",
"login": "Connexion",
"logs": "Journaux",
"migrate": "Migrer",
"missing": "Manquant",
@@ -812,9 +859,11 @@
"troubleshoot": "Dépannage"
},
"settingsCategories": {
"3D": "3D",
"About": "À Propos",
"Appearance": "Apparence",
"BrushAdjustment": "Ajustement de Brosse",
"Camera": "Caméra",
"Canvas": "Toile",
"ColorPalette": "Palette de Couleurs",
"Comfy": "Confort",
@@ -831,6 +880,7 @@
"Link": "Lien",
"LinkRelease": "Libération de Lien",
"LiteGraph": "Lite Graph",
"Load 3D": "Charger 3D",
"Locale": "Locale",
"Mask Editor": "Éditeur de Masque",
"Menu": "Menu",
@@ -845,6 +895,7 @@
"QueueButton": "Bouton de File d'Attente",
"Reroute": "Réacheminement",
"RerouteBeta": "Reroute Beta",
"Scene": "Scène",
"Server": "Serveur",
"Server-Config": "Config-Serveur",
"Settings": "Paramètres",
@@ -1028,6 +1079,21 @@
"next": "Suivant",
"selectUser": "Sélectionnez un utilisateur"
},
"validation": {
"invalidEmail": "Adresse e-mail invalide",
"maxLength": "Ne doit pas dépasser {length} caractères",
"minLength": "Doit contenir au moins {length} caractères",
"password": {
"lowercase": "Doit contenir au moins une lettre minuscule",
"match": "Les mots de passe doivent correspondre",
"minLength": "Doit contenir entre 8 et 32 caractères",
"number": "Doit contenir au moins un chiffre",
"requirements": "Exigences du mot de passe",
"special": "Doit contenir au moins un caractère spécial",
"uppercase": "Doit contenir au moins une lettre majuscule"
},
"required": "Requis"
},
"welcome": {
"getStarted": "Commencer",
"title": "Bienvenue sur ComfyUI"

View File

@@ -108,6 +108,22 @@
"Straight": "Droit"
}
},
"Comfy_Load3D_CameraType": {
"name": "Type de Caméra",
"options": {
"orthographic": "orthographique",
"perspective": "perspective"
},
"tooltip": "Contrôle si la caméra est en perspective ou orthographique par défaut lorsqu'un nouveau widget 3D est créé. Ce défaut peut toujours être basculé individuellement pour chaque widget après sa création."
},
"Comfy_Load3D_ShowGrid": {
"name": "Afficher la Grille",
"tooltip": "Basculer pour afficher la grille par défaut"
},
"Comfy_Load3D_ShowPreview": {
"name": "Afficher l'Aperçu",
"tooltip": "Basculer pour afficher l'aperçu par défaut"
},
"Comfy_Locale": {
"name": "Langue"
},

View File

@@ -1,4 +1,49 @@
{
"apiNodesCostBreakdown": {
"costPerRun": "実行あたりのコスト",
"title": "APIード",
"totalCost": "合計コスト"
},
"apiNodesSignInDialog": {
"message": "このワークフローにはAPIードが含まれており、実行するためにはアカウントにサインインする必要があります。",
"title": "APIードを使用するためにはサインインが必要です"
},
"auth": {
"login": {
"andText": "および",
"confirmPasswordLabel": "パスワードの確認",
"confirmPasswordPlaceholder": "もう一度同じパスワードを入力してください",
"emailLabel": "メール",
"emailPlaceholder": "メールアドレスを入力してください",
"failed": "ログイン失敗",
"forgotPassword": "パスワードを忘れましたか?",
"loginButton": "ログイン",
"loginWithGithub": "Githubでログイン",
"loginWithGoogle": "Googleでログイン",
"newUser": "新規ユーザーですか?",
"orContinueWith": "または以下で続ける",
"passwordLabel": "パスワード",
"passwordPlaceholder": "パスワードを入力してください",
"privacyLink": "プライバシーポリシー",
"signUp": "サインアップ",
"success": "ログイン成功",
"termsLink": "利用規約",
"termsText": "「次へ」または「サインアップ」をクリックすると、私たちの",
"title": "アカウントにログインする"
},
"signup": {
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
"emailLabel": "メール",
"emailPlaceholder": "メールアドレスを入力してください",
"passwordLabel": "パスワード",
"passwordPlaceholder": "新しいパスワードを入力してください",
"signIn": "サインイン",
"signUpButton": "サインアップ",
"signUpWithGithub": "Githubでサインアップ",
"signUpWithGoogle": "Googleでサインアップ",
"title": "アカウントを作成する"
}
},
"clipboard": {
"errorMessage": "クリップボードへのコピーに失敗しました",
"errorNotSupported": "お使いのブラウザではクリップボードAPIがサポートされていません",
@@ -172,9 +217,11 @@
"installing": "インストール中",
"interrupted": "中断されました",
"keybinding": "キーバインディング",
"learnMore": "詳細を学ぶ",
"loadAllFolders": "すべてのフォルダーを読み込む",
"loadWorkflow": "ワークフローを読み込む",
"loading": "読み込み中",
"login": "ログイン",
"logs": "ログ",
"migrate": "移行する",
"missing": "不足している",
@@ -812,9 +859,11 @@
"troubleshoot": "トラブルシューティング"
},
"settingsCategories": {
"3D": "3D",
"About": "情報",
"Appearance": "外観",
"BrushAdjustment": "ブラシ調整",
"Camera": "カメラ",
"Canvas": "キャンバス",
"ColorPalette": "カラーパレット",
"Comfy": "Comfy",
@@ -831,6 +880,7 @@
"Link": "リンク",
"LinkRelease": "リンク解除",
"LiteGraph": "Lite Graph",
"Load 3D": "3Dを読み込む",
"Locale": "ロケール",
"Mask Editor": "マスクエディタ",
"Menu": "メニュー",
@@ -845,6 +895,7 @@
"QueueButton": "キューボタン",
"Reroute": "リルート",
"RerouteBeta": "ルート変更ベータ",
"Scene": "シーン",
"Server": "サーバー",
"Server-Config": "サーバー設定",
"Settings": "設定",
@@ -1028,6 +1079,21 @@
"next": "次へ",
"selectUser": "ユーザーを選択"
},
"validation": {
"invalidEmail": "無効なメールアドレス",
"maxLength": "{length}文字以下でなければなりません",
"minLength": "{length}文字以上でなければなりません",
"password": {
"lowercase": "少なくとも1つの小文字を含む必要があります",
"match": "パスワードが一致する必要があります",
"minLength": "8文字から32文字の間でなければなりません",
"number": "少なくとも1つの数字を含む必要があります",
"requirements": "パスワードの要件",
"special": "少なくとも1つの特殊文字を含む必要があります",
"uppercase": "少なくとも1つの大文字を含む必要があります"
},
"required": "必須"
},
"welcome": {
"getStarted": "はじめる",
"title": "ComfyUIへようこそ"

View File

@@ -108,6 +108,22 @@
"Straight": "ストレート"
}
},
"Comfy_Load3D_CameraType": {
"name": "カメラタイプ",
"options": {
"orthographic": "オルソグラフィック",
"perspective": "パースペクティブ"
},
"tooltip": "新しい3Dウィジェットが作成されたときに、デフォルトでカメラが透視投影か平行投影かを制御します。このデフォルトは、作成後に各ウィジェットごとに個別に切り替えることができます。"
},
"Comfy_Load3D_ShowGrid": {
"name": "グリッドを表示",
"tooltip": "デフォルトでグリッドを表示するには切り替えます"
},
"Comfy_Load3D_ShowPreview": {
"name": "プレビューを表示",
"tooltip": "デフォルトでプレビューを表示するには切り替えます"
},
"Comfy_Locale": {
"name": "言語"
},

View File

@@ -1,4 +1,49 @@
{
"apiNodesCostBreakdown": {
"costPerRun": "실행 당 비용",
"title": "API 노드(들)",
"totalCost": "총 비용"
},
"apiNodesSignInDialog": {
"message": "이 워크플로우에는 API 노드가 포함되어 있으며, 실행하려면 계정에 로그인해야 합니다.",
"title": "API 노드 사용에 필요한 로그인"
},
"auth": {
"login": {
"andText": "및",
"confirmPasswordLabel": "비밀번호 확인",
"confirmPasswordPlaceholder": "동일한 비밀번호를 다시 입력하세요",
"emailLabel": "이메일",
"emailPlaceholder": "이메일을 입력하세요",
"failed": "로그인 실패",
"forgotPassword": "비밀번호를 잊으셨나요?",
"loginButton": "로그인",
"loginWithGithub": "Github로 로그인",
"loginWithGoogle": "구글로 로그인",
"newUser": "처음이신가요?",
"orContinueWith": "또는 다음으로 계속",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
"privacyLink": "개인정보 보호정책",
"signUp": "가입하기",
"success": "로그인 성공",
"termsLink": "이용 약관",
"termsText": "\"다음\" 또는 \"가입하기\"를 클릭하면 우리의",
"title": "계정에 로그인"
},
"signup": {
"alreadyHaveAccount": "이미 계정이 있으신가요?",
"emailLabel": "이메일",
"emailPlaceholder": "이메일을 입력하세요",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "새 비밀번호를 입력하세요",
"signIn": "로그인",
"signUpButton": "가입하기",
"signUpWithGithub": "Github로 가입하기",
"signUpWithGoogle": "구글로 가입하기",
"title": "계정 생성"
}
},
"clipboard": {
"errorMessage": "클립보드에 복사하지 못했습니다",
"errorNotSupported": "브라우저가 클립보드 API를 지원하지 않습니다.",
@@ -172,9 +217,11 @@
"installing": "설치 중",
"interrupted": "중단됨",
"keybinding": "키 바인딩",
"learnMore": "더 알아보기",
"loadAllFolders": "모든 폴더 로드",
"loadWorkflow": "워크플로 로드",
"loading": "로딩 중",
"login": "로그인",
"logs": "로그",
"migrate": "이전(migrate)",
"missing": "누락됨",
@@ -812,9 +859,11 @@
"troubleshoot": "문제 해결"
},
"settingsCategories": {
"3D": "3D",
"About": "정보",
"Appearance": "모양",
"BrushAdjustment": "브러시 조정",
"Camera": "카메라",
"Canvas": "캔버스",
"ColorPalette": "색상 팔레트",
"Comfy": "Comfy",
@@ -831,6 +880,7 @@
"Link": "링크",
"LinkRelease": "링크 해제",
"LiteGraph": "LiteGraph",
"Load 3D": "3D 불러오기",
"Locale": "언어 설정",
"Mask Editor": "마스크 편집기",
"Menu": "메뉴",
@@ -845,6 +895,7 @@
"QueueButton": "실행 큐 버튼",
"Reroute": "경유점",
"RerouteBeta": "경유점 (베타)",
"Scene": "장면",
"Server": "서버",
"Server-Config": "서버 구성",
"Settings": "설정",
@@ -1028,6 +1079,21 @@
"next": "다음",
"selectUser": "사용자 선택"
},
"validation": {
"invalidEmail": "유효하지 않은 이메일 주소",
"maxLength": "{length}자를 초과할 수 없습니다",
"minLength": "{length}자 이상이어야 합니다",
"password": {
"lowercase": "적어도 하나의 소문자를 포함해야 합니다",
"match": "비밀번호가 일치해야 합니다",
"minLength": "8자에서 32자 사이여야 합니다",
"number": "적어도 하나의 숫자를 포함해야 합니다",
"requirements": "비밀번호 요구사항",
"special": "적어도 하나의 특수 문자를 포함해야 합니다",
"uppercase": "적어도 하나의 대문자를 포함해야 합니다"
},
"required": "필수"
},
"welcome": {
"getStarted": "시작하기",
"title": "ComfyUI에 오신 것을 환영합니다"

View File

@@ -108,6 +108,22 @@
"Straight": "직선"
}
},
"Comfy_Load3D_CameraType": {
"name": "카메라 유형",
"options": {
"orthographic": "직교법",
"perspective": "원근법"
},
"tooltip": "새로운 3D 위젯이 생성될 때 카메라가 기본적으로 원근법 또는 직교법을 사용하는지를 제어합니다. 이 기본값은 생성 후 각 위젯별로 개별적으로 전환할 수 있습니다."
},
"Comfy_Load3D_ShowGrid": {
"name": "그리드 표시",
"tooltip": "기본적으로 그리드를 표시하도록 전환"
},
"Comfy_Load3D_ShowPreview": {
"name": "미리보기 표시",
"tooltip": "기본적으로 미리보기를 표시하도록 전환"
},
"Comfy_Locale": {
"name": "언어"
},

View File

@@ -1,4 +1,49 @@
{
"apiNodesCostBreakdown": {
"costPerRun": "Стоимость за запуск",
"title": "API Node(s)",
"totalCost": "Общая стоимость"
},
"apiNodesSignInDialog": {
"message": "Этот рабочий процесс содержит API Nodes, которые требуют входа в вашу учетную запись для выполнения.",
"title": "Требуется вход для использования API Nodes"
},
"auth": {
"login": {
"andText": "и",
"confirmPasswordLabel": "Подтвердите пароль",
"confirmPasswordPlaceholder": "Введите тот же пароль еще раз",
"emailLabel": "Электронная почта",
"emailPlaceholder": "Введите вашу электронную почту",
"failed": "Вход не удался",
"forgotPassword": "Забыли пароль?",
"loginButton": "Войти",
"loginWithGithub": "Войти через Github",
"loginWithGoogle": "Войти через Google",
"newUser": "Вы здесь впервые?",
"orContinueWith": "Или продолжить с",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите ваш пароль",
"privacyLink": "Политикой конфиденциальности",
"signUp": "Зарегистрироваться",
"success": "Вход выполнен успешно",
"termsLink": "Условиями использования",
"termsText": "Нажимая \"Далее\" или \"Зарегистрироваться\", вы соглашаетесь с нашими",
"title": "Войдите в свой аккаунт"
},
"signup": {
"alreadyHaveAccount": "Уже есть аккаунт?",
"emailLabel": "Электронная почта",
"emailPlaceholder": "Введите вашу электронную почту",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите новый пароль",
"signIn": "Войти",
"signUpButton": "Зарегистрироваться",
"signUpWithGithub": "Зарегистрироваться через Github",
"signUpWithGoogle": "Зарегистрироваться через Google",
"title": "Создать аккаунт"
}
},
"clipboard": {
"errorMessage": "Не удалось скопировать в буфер обмена",
"errorNotSupported": "API буфера обмена не поддерживается в вашем браузере",
@@ -172,9 +217,11 @@
"installing": "Установка",
"interrupted": "Прервано",
"keybinding": "Привязка клавиш",
"learnMore": "Узнать больше",
"loadAllFolders": "Загрузить все папки",
"loadWorkflow": "Загрузить рабочий процесс",
"loading": "Загрузка",
"login": "Вход",
"logs": "Логи",
"migrate": "Мигрировать",
"missing": "Отсутствует",
@@ -812,9 +859,11 @@
"troubleshoot": "Устранение неполадок"
},
"settingsCategories": {
"3D": "3D",
"About": "О программе",
"Appearance": "Внешний вид",
"BrushAdjustment": "Настройка кисти",
"Camera": "Камера",
"Canvas": "Холст",
"ColorPalette": "Цветовая палитра",
"Comfy": "Comfy",
@@ -831,6 +880,7 @@
"Link": "Ссылка",
"LinkRelease": "Освобождение ссылки",
"LiteGraph": "Lite Graph",
"Load 3D": "Загрузить 3D",
"Locale": "Локализация",
"Mask Editor": "Редактор масок",
"Menu": "Меню",
@@ -845,6 +895,7 @@
"QueueButton": "Кнопка очереди",
"Reroute": "Перенаправление",
"RerouteBeta": "Бета-версия перенаправления",
"Scene": "Сцена",
"Server": "Сервер",
"Server-Config": "Настройки сервера",
"Settings": "Настройки",
@@ -1028,6 +1079,21 @@
"next": "Далее",
"selectUser": "Выберите пользователя"
},
"validation": {
"invalidEmail": "Недействительный адрес электронной почты",
"maxLength": "Должно быть не более {length} символов",
"minLength": "Должно быть не менее {length} символов",
"password": {
"lowercase": "Должен содержать хотя бы одну строчную букву",
"match": "Пароли должны совпадать",
"minLength": "Должно быть от 8 до 32 символов",
"number": "Должен содержать хотя бы одну цифру",
"requirements": "Требования к паролю",
"special": "Должен содержать хотя бы один специальный символ",
"uppercase": "Должен содержать хотя бы одну заглавную букву"
},
"required": "Обязательно"
},
"welcome": {
"getStarted": "Начать",
"title": "Добро пожаловать в ComfyUI"

View File

@@ -108,6 +108,22 @@
"Straight": "Прямой"
}
},
"Comfy_Load3D_CameraType": {
"name": "Тип камеры",
"options": {
"orthographic": "ортографическая",
"perspective": "перспективная"
},
"tooltip": "Управляет тем, является ли камера перспективной или ортографической по умолчанию при создании нового 3D-виджета. Это значение по умолчанию все еще может быть переключено индивидуально для каждого виджета после его создания."
},
"Comfy_Load3D_ShowGrid": {
"name": "Показать сетку",
"tooltip": "Переключиться, чтобы показывать сетку по умолчанию"
},
"Comfy_Load3D_ShowPreview": {
"name": "Показать предварительный просмотр",
"tooltip": "Переключиться, чтобы показывать предварительный просмотр по умолчанию"
},
"Comfy_Locale": {
"name": "Язык"
},

View File

@@ -1,4 +1,49 @@
{
"apiNodesCostBreakdown": {
"costPerRun": "每次运行的成本",
"title": "API节点",
"totalCost": "总成本"
},
"apiNodesSignInDialog": {
"message": "此工作流包含API节点需要您登录账户才能运行。",
"title": "使用API节点需要登录"
},
"auth": {
"login": {
"andText": "和",
"confirmPasswordLabel": "确认密码",
"confirmPasswordPlaceholder": "再次输入相同的密码",
"emailLabel": "电子邮件",
"emailPlaceholder": "输入您的电子邮件",
"failed": "登录失败",
"forgotPassword": "忘记密码?",
"loginButton": "登录",
"loginWithGithub": "使用Github登录",
"loginWithGoogle": "使用Google登录",
"newUser": "新来的?",
"orContinueWith": "或者继续使用",
"passwordLabel": "密码",
"passwordPlaceholder": "输入您的密码",
"privacyLink": "隐私政策",
"signUp": "注册",
"success": "登录成功",
"termsLink": "使用条款",
"termsText": "点击“下一步”或“注册”即表示您同意我们的",
"title": "登录您的账户"
},
"signup": {
"alreadyHaveAccount": "已经有账户了?",
"emailLabel": "电子邮件",
"emailPlaceholder": "输入您的电子邮件",
"passwordLabel": "密码",
"passwordPlaceholder": "输入新密码",
"signIn": "登录",
"signUpButton": "注册",
"signUpWithGithub": "使用Github注册",
"signUpWithGoogle": "使用Google注册",
"title": "创建一个账户"
}
},
"clipboard": {
"errorMessage": "复制到剪贴板失败",
"errorNotSupported": "您的浏览器不支持剪贴板API",
@@ -172,9 +217,11 @@
"installing": "正在安装",
"interrupted": "已中断",
"keybinding": "按键绑定",
"learnMore": "了解更多",
"loadAllFolders": "加载所有文件夹",
"loadWorkflow": "加载工作流",
"loading": "加载中",
"login": "登录",
"logs": "日志",
"migrate": "迁移",
"missing": "缺失",
@@ -812,9 +859,11 @@
"troubleshoot": "故障排除"
},
"settingsCategories": {
"3D": "3D",
"About": "关于",
"Appearance": "外观",
"BrushAdjustment": "画笔调整",
"Camera": "相机",
"Canvas": "画布",
"ColorPalette": "色彩主题",
"Comfy": "Comfy",
@@ -831,6 +880,7 @@
"Link": "连线",
"LinkRelease": "释放链接",
"LiteGraph": "画面",
"Load 3D": "加载3D",
"Locale": "区域设置",
"Mask Editor": "遮罩编辑器",
"Menu": "菜单",
@@ -845,6 +895,7 @@
"QueueButton": "执行按钮",
"Reroute": "重新路由",
"RerouteBeta": "转接点 Beta",
"Scene": "场景",
"Server": "服务器",
"Server-Config": "服务器配置",
"Settings": "设置",
@@ -1028,6 +1079,21 @@
"next": "下一步",
"selectUser": "选择用户"
},
"validation": {
"invalidEmail": "无效的电子邮件地址",
"maxLength": "不能超过{length}个字符",
"minLength": "必须至少有{length}个字符",
"password": {
"lowercase": "必须包含至少一个小写字母",
"match": "密码必须匹配",
"minLength": "必须在8到32个字符之间",
"number": "必须包含至少一个数字",
"requirements": "密码要求",
"special": "必须包含至少一个特殊字符",
"uppercase": "必须包含至少一个大写字母"
},
"required": "必填"
},
"welcome": {
"getStarted": "开始使用",
"title": "欢迎使用 ComfyUI"

View File

@@ -108,6 +108,22 @@
"Straight": "直角线"
}
},
"Comfy_Load3D_CameraType": {
"name": "摄像机类型",
"options": {
"orthographic": "正交",
"perspective": "透视"
},
"tooltip": "控制创建新的3D小部件时默认的相机是透视还是正交。这个默认设置仍然可以在创建后为每个小部件单独切换。"
},
"Comfy_Load3D_ShowGrid": {
"name": "显示网格",
"tooltip": "默认显示网格开关"
},
"Comfy_Load3D_ShowPreview": {
"name": "显示预览",
"tooltip": "默认显示预览开关"
},
"Comfy_Locale": {
"name": "语言"
},

View File

@@ -2,6 +2,7 @@ import '@comfyorg/litegraph/style.css'
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { initializeApp } from 'firebase/app'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -9,8 +10,10 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import '@/assets/css/style.css'
import { FIREBASE_CONFIG } from '@/config/firebase'
import router from '@/router'
import App from './App.vue'
@@ -23,6 +26,8 @@ const ComfyUIPreset = definePreset(Aura, {
}
})
const firebaseApp = initializeApp(FIREBASE_CONFIG)
const app = createApp(App)
const pinia = createPinia()
Sentry.init({
@@ -58,4 +63,8 @@ app
.use(ToastService)
.use(pinia)
.use(i18n)
.use(VueFire, {
firebaseApp,
modules: [VueFireAuth()]
})
.mount('#vue-app')

View File

@@ -440,6 +440,9 @@ const zSettings = z.object({
'Comfy.MaskEditor.UseNewEditor': z.boolean(),
'Comfy.MaskEditor.BrushAdjustmentSpeed': z.number(),
'Comfy.MaskEditor.UseDominantAxis': z.boolean(),
'Comfy.Load3D.ShowGrid': z.boolean(),
'Comfy.Load3D.ShowPreview': z.boolean(),
'Comfy.Load3D.CameraType': z.enum(['perspective', 'orthographic']),
'pysssss.SnapToGrid': z.boolean(),
/** VHS setting is used for queue video preview support. */
'VHS.AdvancedPreviews': z.boolean(),

View File

@@ -0,0 +1,36 @@
import { z } from 'zod'
import { t } from '@/i18n'
export const signInSchema = z.object({
email: z
.string()
.email(t('validation.invalidEmail'))
.min(1, t('validation.required')),
password: z.string().min(1, t('validation.required'))
})
export type SignInData = z.infer<typeof signInSchema>
export const signUpSchema = z
.object({
email: z
.string()
.email(t('validation.invalidEmail'))
.min(1, t('validation.required')),
password: z
.string()
.min(8, t('validation.minLength', { length: 8 }))
.max(32, t('validation.maxLength', { length: 32 }))
.regex(/[A-Z]/, t('validation.password.uppercase'))
.regex(/[a-z]/, t('validation.password.lowercase'))
.regex(/\d/, t('validation.password.number'))
.regex(/[^A-Za-z0-9]/, t('validation.password.special')),
confirmPassword: z.string().min(1, t('validation.required'))
})
.refine((data) => data.password === data.confirmPassword, {
message: t('validation.password.match'),
path: ['confirmPassword']
})
export type SignUpData = z.infer<typeof signUpSchema>

View File

@@ -1,3 +1,4 @@
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
import IssueReportDialogContent from '@/components/dialog/content/IssueReportDialogContent.vue'
@@ -6,9 +7,11 @@ import ManagerProgressDialogContent from '@/components/dialog/content/ManagerPro
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SignInContent from '@/components/dialog/content/SignInContent.vue'
import ManagerDialogContent from '@/components/dialog/content/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/components/dialog/content/manager/ManagerHeader.vue'
import ManagerProgressFooter from '@/components/dialog/footer/ManagerProgressFooter.vue'
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
import ManagerProgressHeader from '@/components/dialog/header/ManagerProgressHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
@@ -16,6 +19,7 @@ import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkfl
import { t } from '@/i18n'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { type ShowDialogOptions, useDialogStore } from '@/stores/dialogStore'
import { ApiNodeCost } from '@/types/apiNodeTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
export type ConfirmationDialogType =
@@ -216,6 +220,54 @@ export const useDialogService = () => {
})
}
/**
* Shows a dialog requiring sign in for API nodes
* @returns Promise that resolves to true if user clicks login, false if cancelled
*/
async function showApiNodesSignInDialog(
apiNodes: ApiNodeCost[]
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
dialogStore.showDialog({
key: 'api-nodes-signin',
component: ApiNodesSignInContent,
props: {
apiNodes,
onLogin: () => showSignInDialog().then((result) => resolve(result)),
onCancel: () => resolve(false)
},
headerComponent: ComfyOrgHeader,
dialogComponentProps: {
closable: false,
onClose: () => resolve(false)
}
})
}).then((result) => {
dialogStore.closeDialog({ key: 'api-nodes-signin' })
return result
})
}
async function showSignInDialog(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
dialogStore.showDialog({
key: 'global-signin',
component: SignInContent,
headerComponent: ComfyOrgHeader,
props: {
onSuccess: () => resolve(true)
},
dialogComponentProps: {
closable: false,
onClose: () => resolve(false)
}
})
}).then((result) => {
dialogStore.closeDialog({ key: 'global-signin' })
return result
})
}
async function prompt({
title,
message,
@@ -300,6 +352,8 @@ export const useDialogService = () => {
showManagerDialog,
showManagerProgressDialog,
showErrorDialog,
showApiNodesSignInDialog,
showSignInDialog,
prompt,
confirm
}

View File

@@ -0,0 +1,118 @@
import {
type Auth,
GithubAuthProvider,
GoogleAuthProvider,
type User,
type UserCredential,
createUserWithEmailAndPassword,
onAuthStateChanged,
signInWithEmailAndPassword,
signInWithPopup,
signOut
} from 'firebase/auth'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useFirebaseAuth } from 'vuefire'
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// State
const loading = ref(false)
const error = ref<string | null>(null)
const currentUser = ref<User | null>(null)
const isInitialized = ref(false)
// Providers
const googleProvider = new GoogleAuthProvider()
const githubProvider = new GithubAuthProvider()
// Getters
const isAuthenticated = computed(() => !!currentUser.value)
const userEmail = computed(() => currentUser.value?.email)
const userId = computed(() => currentUser.value?.uid)
// Get auth from VueFire and listen for auth state changes
const auth = useFirebaseAuth()
if (auth) {
onAuthStateChanged(auth, (user) => {
currentUser.value = user
isInitialized.value = true
})
} else {
error.value = 'Firebase Auth not available from VueFire'
}
const executeAuthAction = async <T>(
action: (auth: Auth) => Promise<T>
): Promise<T> => {
if (!auth) throw new Error('Firebase Auth not initialized')
loading.value = true
error.value = null
try {
return await action(auth)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Unknown error'
throw e
} finally {
loading.value = false
}
}
const login = async (
email: string,
password: string
): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
signInWithEmailAndPassword(authInstance, email, password)
)
const register = async (
email: string,
password: string
): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
createUserWithEmailAndPassword(authInstance, email, password)
)
const loginWithGoogle = async (): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
signInWithPopup(authInstance, googleProvider)
)
const loginWithGithub = async (): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
signInWithPopup(authInstance, githubProvider)
)
const logout = async (): Promise<void> =>
executeAuthAction((authInstance) => signOut(authInstance))
const getIdToken = async (): Promise<string | null> => {
if (currentUser.value) {
return currentUser.value.getIdToken()
}
return null
}
return {
// State
loading,
error,
currentUser,
isInitialized,
// Getters
isAuthenticated,
userEmail,
userId,
// Actions
login,
register,
logout,
getIdToken,
loginWithGoogle,
loginWithGithub
}
})

View File

@@ -0,0 +1,4 @@
export interface ApiNodeCost {
name: string
cost: number
}

View File

@@ -21,6 +21,43 @@ export interface paths {
patch?: never
trace?: never
}
'/customer': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/**
* Create a new customer
* @description Creates a new customer using the provided token. No request body is needed as user information is extracted from the token.
*/
post: operations['createCustomer']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/customer/credit': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Initiates a Credit Purchase. */
post: operations['InitiateCreditPurchase']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/upload-artifact': {
parameters: {
query?: never
@@ -900,6 +937,98 @@ export interface paths {
patch?: never
trace?: never
}
'/proxy/ideogram/generate': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/**
* Proxy request to Ideogram for image generation
* @description Forwards image generation requests to Ideogram's API and returns the results.
*/
post: operations['ideogramGenerate']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/webhook/metronome/zero-balance': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** receive alert on remaining balance is 0 */
post: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': {
properties?: {
/** @description the metronome customer id */
customer_id?: string
/** @description the customer remaining balance */
remaining_balance?: number
}
}
}
}
responses: {
/** @description Webhook processed succesfully */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['IdeogramGenerateResponse']
}
}
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Internal Server Error (proxy or upstream issue) */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
}
export type webhooks = Record<string, never>
export interface components {
@@ -1225,6 +1354,81 @@ export interface components {
/** @description The pip freeze output */
pip_freeze?: string
}
Customer: {
/** @description The firebase UID of the user */
id: string
/** @description The email address for this user */
email?: string
/** @description The name for this user */
name?: string
/**
* Format: date-time
* @description The date and time the user was created
*/
createdAt?: string
/**
* Format: date-time
* @description The date and time the user was last updated
*/
updatedAt?: string
}
/** @description Parameters for the Ideogram generation proxy request. Based on Ideogram's API. */
IdeogramGenerateRequest: {
/** @description The image generation request parameters. */
image_request: {
/** @description Required. The prompt to use to generate the image. */
prompt: string
/** @description Optional. The aspect ratio (e.g., 'ASPECT_16_9', 'ASPECT_1_1'). Cannot be used with resolution. Defaults to 'ASPECT_1_1' if unspecified. */
aspect_ratio?: string
/** @description Optional. The model used (e.g., 'V_2', 'V_2A_TURBO'). Defaults to 'V_2' if unspecified. */
model?: string
/** @description Optional. MagicPrompt usage ('AUTO', 'ON', 'OFF'). */
magic_prompt_option?: string
/**
* Format: int64
* @description Optional. A number between 0 and 2147483647.
*/
seed?: number
/** @description Optional. Style type ('AUTO', 'GENERAL', 'REALISTIC', 'DESIGN', 'RENDER_3D', 'ANIME'). Only for models V_2 and above. */
style_type?: string
/** @description Optional. Description of what to exclude. Only for V_1, V_1_TURBO, V_2, V_2_TURBO. */
negative_prompt?: string
/**
* @description Optional. Number of images to generate (1-8). Defaults to 1.
* @default 1
*/
num_images: number
/** @description Optional. Resolution (e.g., 'RESOLUTION_1024_1024'). Only for model V_2. Cannot be used with aspect_ratio. */
resolution?: string
/** @description Optional. Color palette object. Only for V_2, V_2_TURBO. */
color_palette?: {
[key: string]: unknown
}
}
}
/** @description Response from the Ideogram image generation API. */
IdeogramGenerateResponse: {
/**
* Format: date-time
* @description Timestamp when the generation was created.
*/
created?: string
/** @description Array of generated image information. */
data?: {
/** @description The prompt used to generate this image. */
prompt?: string
/** @description The resolution of the generated image (e.g., '1024x1024'). */
resolution?: string
/** @description Indicates whether the image is considered safe. */
is_image_safe?: boolean
/** @description The seed value used for this generation. */
seed?: number
/** @description URL to the generated image. */
url?: string
/** @description The style type used for generation (e.g., 'REALISTIC', 'ANIME'). */
style_type?: string
}[]
}
}
responses: never
parameters: never
@@ -1268,6 +1472,111 @@ export interface operations {
}
}
}
createCustomer: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description Customer created successfully */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Customer']
}
}
/** @description Bad request, invalid token or user already exists */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized or invalid token */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
InitiateCreditPurchase: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': {
/**
* Format: int64
* @description the amount of the checkout transaction in micro value
*/
amount_micros?: number
/** @description the currency used in the checkout transaction */
currency?: string
}
}
}
responses: {
/** @description Customer Checkout created successfully */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': {
/** @description the url to redirect the customer */
checkout_url?: string
}
}
}
/** @description Bad request, invalid token or user already exists */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized or invalid token */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
getWorkflowResult: {
parameters: {
query?: never
@@ -3417,4 +3726,80 @@ export interface operations {
}
}
}
ideogramGenerate: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['IdeogramGenerateRequest']
}
}
responses: {
/** @description Successful response from Ideogram proxy */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['IdeogramGenerateResponse']
}
}
/** @description Bad Request (invalid input to proxy) */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded (either from proxy or Ideogram) */
429: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal Server Error (proxy or upstream issue) */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Bad Gateway (error communicating with Ideogram) */
502: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Gateway Timeout (Ideogram took too long to respond) */
504: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
}

View File

@@ -0,0 +1,364 @@
import * as firebaseAuth from 'firebase/auth'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn()
}))
vi.mock('firebase/auth', () => ({
signInWithEmailAndPassword: vi.fn(),
createUserWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
onAuthStateChanged: vi.fn(),
signInWithPopup: vi.fn(),
GoogleAuthProvider: vi.fn(),
GithubAuthProvider: vi.fn()
}))
describe('useFirebaseAuthStore', () => {
let store: ReturnType<typeof useFirebaseAuthStore>
let authStateCallback: (user: any) => void
const mockAuth = {
/* mock Auth object */
}
const mockUser = {
uid: 'test-user-id',
email: 'test@example.com',
getIdToken: vi.fn().mockResolvedValue('mock-id-token')
}
beforeEach(() => {
vi.resetAllMocks()
// Mock useFirebaseAuth to return our mock auth object
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
// Mock onAuthStateChanged to capture the callback and simulate initial auth state
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
(_, callback) => {
authStateCallback = callback as (user: any) => void
// Call the callback with our mock user
;(callback as (user: any) => void)(mockUser)
// Return an unsubscribe function
return vi.fn()
}
)
// Initialize Pinia
setActivePinia(createPinia())
store = useFirebaseAuthStore()
})
it('should initialize with the current user', () => {
expect(store.currentUser).toEqual(mockUser)
expect(store.isAuthenticated).toBe(true)
expect(store.userEmail).toBe('test@example.com')
expect(store.userId).toBe('test-user-id')
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should properly clean up error state between operations', async () => {
// First, cause an error
const mockError = new Error('Invalid password')
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockRejectedValueOnce(
mockError
)
try {
await store.login('test@example.com', 'wrong-password')
} catch (e) {
// Error expected
}
expect(store.error).toBe('Invalid password')
// Now, succeed on next attempt
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce({
user: mockUser
} as any)
await store.login('test@example.com', 'correct-password')
// Error should be cleared
expect(store.error).toBe(null)
})
it('should handle auth initialization failure', async () => {
// Mock auth as null to simulate initialization failure
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(null)
// Create a new store instance
setActivePinia(createPinia())
const uninitializedStore = useFirebaseAuthStore()
// Check that isInitialized is false
expect(uninitializedStore.isInitialized).toBe(false)
// Verify store actions throw appropriate errors
await expect(
uninitializedStore.login('test@example.com', 'password')
).rejects.toThrow('Firebase Auth not initialized')
})
describe('login', () => {
it('should login with valid credentials', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as any
)
const result = await store.login('test@example.com', 'password')
expect(firebaseAuth.signInWithEmailAndPassword).toHaveBeenCalledWith(
mockAuth,
'test@example.com',
'password'
)
expect(result).toEqual(mockUserCredential)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should handle login errors', async () => {
const mockError = new Error('Invalid password')
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockRejectedValue(
mockError
)
await expect(
store.login('test@example.com', 'wrong-password')
).rejects.toThrow('Invalid password')
expect(firebaseAuth.signInWithEmailAndPassword).toHaveBeenCalledWith(
mockAuth,
'test@example.com',
'wrong-password'
)
expect(store.loading).toBe(false)
expect(store.error).toBe('Invalid password')
})
})
describe('register', () => {
it('should register a new user', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
mockUserCredential as any
)
const result = await store.register('new@example.com', 'password')
expect(firebaseAuth.createUserWithEmailAndPassword).toHaveBeenCalledWith(
mockAuth,
'new@example.com',
'password'
)
expect(result).toEqual(mockUserCredential)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should handle registration errors', async () => {
const mockError = new Error('Email already in use')
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockRejectedValue(
mockError
)
await expect(
store.register('existing@example.com', 'password')
).rejects.toThrow('Email already in use')
expect(firebaseAuth.createUserWithEmailAndPassword).toHaveBeenCalledWith(
mockAuth,
'existing@example.com',
'password'
)
expect(store.loading).toBe(false)
expect(store.error).toBe('Email already in use')
})
it('should handle concurrent login attempts correctly', async () => {
// Set up multiple login promises
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as any
)
const loginPromise1 = store.login('user1@example.com', 'password1')
const loginPromise2 = store.login('user2@example.com', 'password2')
// Resolve both promises
await Promise.all([loginPromise1, loginPromise2])
// Verify the loading state is reset
expect(store.loading).toBe(false)
})
})
describe('logout', () => {
it('should sign out the user', async () => {
vi.mocked(firebaseAuth.signOut).mockResolvedValue(undefined)
await store.logout()
expect(firebaseAuth.signOut).toHaveBeenCalledWith(mockAuth)
})
it('should handle logout errors', async () => {
const mockError = new Error('Network error')
vi.mocked(firebaseAuth.signOut).mockRejectedValue(mockError)
await expect(store.logout()).rejects.toThrow('Network error')
expect(firebaseAuth.signOut).toHaveBeenCalledWith(mockAuth)
expect(store.error).toBe('Network error')
})
})
describe('getIdToken', () => {
it('should return the user ID token', async () => {
// FIX 2: Reset the mock and set a specific return value
mockUser.getIdToken.mockReset()
mockUser.getIdToken.mockResolvedValue('mock-id-token')
const token = await store.getIdToken()
expect(mockUser.getIdToken).toHaveBeenCalled()
expect(token).toBe('mock-id-token')
})
it('should return null when no user is logged in', async () => {
// Simulate logged out state
authStateCallback(null)
const token = await store.getIdToken()
expect(token).toBeNull()
})
it('should return null for token after login and logout sequence', async () => {
// Setup mock for login
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as any
)
// Login
await store.login('test@example.com', 'password')
// Simulate successful auth state update after login
authStateCallback(mockUser)
// Verify we're logged in and can get a token
mockUser.getIdToken.mockReset()
mockUser.getIdToken.mockResolvedValue('mock-id-token')
expect(await store.getIdToken()).toBe('mock-id-token')
// Setup mock for logout
vi.mocked(firebaseAuth.signOut).mockResolvedValue(undefined)
// Logout
await store.logout()
// Simulate successful auth state update after logout
authStateCallback(null)
// Verify token is null after logout
const tokenAfterLogout = await store.getIdToken()
expect(tokenAfterLogout).toBeNull()
})
})
describe('social authentication', () => {
describe('loginWithGoogle', () => {
it('should sign in with Google', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as any
)
const result = await store.loginWithGoogle()
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
mockAuth,
expect.any(firebaseAuth.GoogleAuthProvider)
)
expect(result).toEqual(mockUserCredential)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should handle Google sign in errors', async () => {
const mockError = new Error('Google authentication failed')
vi.mocked(firebaseAuth.signInWithPopup).mockRejectedValue(mockError)
await expect(store.loginWithGoogle()).rejects.toThrow(
'Google authentication failed'
)
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
mockAuth,
expect.any(firebaseAuth.GoogleAuthProvider)
)
expect(store.loading).toBe(false)
expect(store.error).toBe('Google authentication failed')
})
})
describe('loginWithGithub', () => {
it('should sign in with Github', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as any
)
const result = await store.loginWithGithub()
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
mockAuth,
expect.any(firebaseAuth.GithubAuthProvider)
)
expect(result).toEqual(mockUserCredential)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should handle Github sign in errors', async () => {
const mockError = new Error('Github authentication failed')
vi.mocked(firebaseAuth.signInWithPopup).mockRejectedValue(mockError)
await expect(store.loginWithGithub()).rejects.toThrow(
'Github authentication failed'
)
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
mockAuth,
expect.any(firebaseAuth.GithubAuthProvider)
)
expect(store.loading).toBe(false)
expect(store.error).toBe('Github authentication failed')
})
})
it('should handle concurrent social login attempts correctly', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as any
)
const googleLoginPromise = store.loginWithGoogle()
const githubLoginPromise = store.loginWithGithub()
await Promise.all([googleLoginPromise, githubLoginPromise])
expect(store.loading).toBe(false)
})
})
})

View File

@@ -8,7 +8,7 @@
"incremental": true,
"sourceMap": true,
"esModuleInterop": true,
"moduleResolution": "Node",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,