Merge branch 'main' into test-pr4654-integration-v2

Merge latest main branch changes including:
- Nx monorepo architecture and build system
- Updated package management with pnpm
- Enhanced CI/CD workflows and testing
- UI component improvements and fixes
- Updated documentation and project setup

All conflicts resolved by taking main's infrastructure changes
while preserving our manager migration work.
This commit is contained in:
bymyself
2025-09-01 14:19:03 -07:00
56 changed files with 4988 additions and 2201 deletions

View File

@@ -24,22 +24,22 @@ export const Basic: Story = {
<MoreButton>
<template #default="{ close }">
<IconTextButton
type="secondary"
type="transparent"
label="Settings"
@click="() => { close() }"
>
<template #icon>
<Download />
<Download :size="16" />
</template>
</IconTextButton>
<IconTextButton
type="primary"
type="transparent"
label="Profile"
@click="() => { close() }"
>
<template #icon>
<ScrollText />
<ScrollText :size="16" />
</template>
</IconTextButton>
</template>

View File

@@ -57,14 +57,23 @@
class="w-8 h-8 mt-4"
style="--pc-spinner-color: #000"
/>
<Button
v-else
class="mt-4 w-32"
severity="secondary"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
@click="handleSignOut"
/>
<div v-else class="mt-4 flex flex-col gap-2">
<Button
class="w-32"
severity="secondary"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
@click="handleSignOut"
/>
<Button
v-if="!isApiKeyLogin"
class="w-32"
severity="danger"
:label="$t('auth.deleteAccount.deleteAccount')"
icon="pi pi-trash"
@click="handleDeleteAccount"
/>
</div>
</div>
<!-- Login Section -->
@@ -100,6 +109,7 @@ const dialogService = useDialogService()
const {
loading,
isLoggedIn,
isApiKeyLogin,
isEmailProvider,
userDisplayName,
userEmail,
@@ -107,6 +117,7 @@ const {
providerName,
providerIcon,
handleSignOut,
handleSignIn
handleSignIn,
handleDeleteAccount
} = useCurrentUser()
</script>

View File

@@ -75,11 +75,6 @@ import { formatVersionAnchor } from '@/utils/formatUtil'
const { locale, t } = useI18n()
const releaseStore = useReleaseStore()
// Emit event for parent component
const emit = defineEmits<{
'whats-new-dismissed': []
}>()
// Local state for dismissed status
const isDismissed = ref(false)
@@ -139,10 +134,6 @@ const closePopup = async () => {
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
}
hide()
// Emit event to notify parent that What's New was dismissed
// Parent can then check if conflict modal should be shown
emit('whats-new-dismissed')
}
// const handleCTA = async () => {

View File

@@ -1,9 +1,23 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
const meta: Meta<typeof MultiSelect> = {
// Combine our component props with PrimeVue MultiSelect props
// Since we use v-bind="$attrs", all PrimeVue props are available
interface ExtendedProps extends Partial<MultiSelectProps> {
// Our custom props
label?: string
showSearchBox?: boolean
showSelectedCount?: boolean
showClearButton?: boolean
searchPlaceholder?: string
// Override modelValue type to match our Option type
modelValue?: Array<{ name: string; value: string }>
}
const meta: Meta<ExtendedProps> = {
title: 'Components/Input/MultiSelect',
component: MultiSelect,
tags: ['autodocs'],
@@ -13,7 +27,35 @@ const meta: Meta<typeof MultiSelect> = {
},
options: {
control: 'object'
},
showSearchBox: {
control: 'boolean',
description: 'Toggle searchBar visibility'
},
showSelectedCount: {
control: 'boolean',
description: 'Toggle selected count visibility'
},
showClearButton: {
control: 'boolean',
description: 'Toggle clear button visibility'
},
searchPlaceholder: {
control: 'text'
}
},
args: {
label: 'Select',
options: [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
],
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
}
@@ -25,7 +67,7 @@ export const Default: Story = {
components: { MultiSelect },
setup() {
const selected = ref([])
const options = [
const options = args.options || [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
@@ -38,8 +80,11 @@ export const Default: Story = {
<MultiSelect
v-model="selected"
:options="options"
label="Select Frameworks"
v-bind="args"
:label="args.label"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
@@ -50,10 +95,10 @@ export const Default: Story = {
}
export const WithPreselectedValues: Story = {
render: () => ({
render: (args) => ({
components: { MultiSelect },
setup() {
const options = [
const options = args.options || [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
@@ -61,25 +106,43 @@ export const WithPreselectedValues: Story = {
{ name: 'Rust', value: 'rust' }
]
const selected = ref([options[0], options[1]])
return { selected, options }
return { selected, options, args }
},
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
label="Select Languages"
:label="args.label"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
</div>
</div>
`
})
}),
args: {
label: 'Select Languages',
options: [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
],
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
}
export const MultipleSelectors: Story = {
render: () => ({
render: (args) => ({
components: { MultiSelect },
setup() {
const frameworkOptions = ref([
@@ -114,7 +177,8 @@ export const MultipleSelectors: Story = {
tagOptions,
selectedFrameworks,
selectedProjects,
selectedTags
selectedTags,
args
}
},
template: `
@@ -124,22 +188,34 @@ export const MultipleSelectors: Story = {
v-model="selectedFrameworks"
:options="frameworkOptions"
label="Select Frameworks"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<MultiSelect
v-model="selectedProjects"
:options="projectOptions"
label="Select Projects"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<MultiSelect
v-model="selectedTags"
:options="tagOptions"
label="Select Tags"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
</div>
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<h4 class="font-medium mb-2">Current Selection:</h4>
<div class="space-y-1 text-sm">
<h4 class="font-medium mt-0">Current Selection:</h4>
<div class="flex flex-col text-sm">
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
@@ -147,5 +223,54 @@ export const MultipleSelectors: Story = {
</div>
</div>
`
})
}),
args: {
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
}
export const WithSearchBox: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true
}
}
export const WithSelectedCount: Story = {
...Default,
args: {
...Default.args,
showSelectedCount: true
}
}
export const WithClearButton: Story = {
...Default,
args: {
...Default.args,
showClearButton: true
}
}
export const AllHeaderFeatures: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
}
}
export const CustomSearchPlaceholder: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
searchPlaceholder: 'Filter packages...'
}
}

View File

@@ -1,93 +1,104 @@
<template>
<div class="relative inline-block">
<MultiSelect
v-model="selectedItems"
:options="options"
option-label="name"
unstyled
:placeholder="label"
:max-selected-labels="0"
:pt="pt"
<!--
Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
<MultiSelect
v-model="selectedItems"
v-bind="$attrs"
option-label="name"
unstyled
:max-selected-labels="0"
:pt="pt"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
>
<template
v-if="hasSearchBox || showSelectedCount || hasClearButton"
#header
>
<div class="p-2 flex flex-col gap-y-4 pb-0">
<SearchBox
v-if="hasSearchBox"
v-model="searchQuery"
:has-border="true"
:place-holder="searchPlaceholder"
/>
<div class="flex items-center justify-between">
<span
v-if="showSelectedCount"
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<TextButton
v-if="hasClearButton"
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
@click.stop="selectedItems = []"
/>
</div>
<div class="h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</div>
</template>
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
{{ label }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i-lucide:chevron-down class="text-lg text-neutral-400" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div class="flex items-center gap-2">
<div
class="flex h-4 w-4 p-0.5 flex-shrink-0 items-center justify-center rounded border-[3px] transition-all duration-200"
:class="
slotProps.selected
? 'border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
: 'border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
"
<div class="p-2 flex flex-col pb-0">
<SearchBox
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:show-order="true"
:place-holder="searchPlaceholder"
/>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
>
<i-lucide:check
v-if="slotProps.selected"
class="text-xs text-bold text-white"
/>
</div>
<span>{{ slotProps.option.name }}</span>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<TextButton
v-if="showClearButton"
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
@click.stop="selectedItems = []"
/>
</div>
</template>
</MultiSelect>
<div class="mt-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</div>
</template>
<!-- Selected count badge -->
<div
v-if="selectedCount > 0"
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
>
{{ selectedCount }}
</div>
</div>
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
>
{{ selectedCount }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i-lucide:chevron-down class="text-lg text-neutral-400" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div class="flex items-center gap-2">
<div
class="flex h-4 w-4 p-0.5 flex-shrink-0 items-center justify-center rounded transition-all duration-200"
:class="
slotProps.selected
? 'border-[3px] border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
: 'border-[1px] border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
"
>
<i-lucide:check
v-if="slotProps.selected"
class="text-xs text-bold text-white"
/>
</div>
<Button class="border-none outline-none bg-transparent" unstyled>{{
slotProps.option.name
}}</Button>
</div>
</template>
</MultiSelect>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import MultiSelect, {
MultiSelectPassThroughMethodOptions
} from 'primevue/multiselect'
@@ -99,26 +110,29 @@ import TextButton from '../button/TextButton.vue'
type Option = { name: string; value: string }
defineOptions({
inheritAttrs: false
})
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Static options for the multiselect (when not using async search) */
options: Option[]
/** Show search box in the panel header */
hasSearchBox?: boolean
showSearchBox?: boolean
/** Show selected count text in the panel header */
showSelectedCount?: boolean
/** Show "Clear all" action in the panel header */
hasClearButton?: boolean
showClearButton?: boolean
/** Placeholder for the search input */
searchPlaceholder?: string
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
const {
label,
options,
hasSearchBox = false,
showSearchBox = false,
showSelectedCount = false,
hasClearButton = false,
showClearButton = false,
searchPlaceholder = 'Search...'
} = defineProps<Props>()
@@ -131,7 +145,7 @@ const selectedCount = computed(() => selectedItems.value.length)
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: [
'relative inline-flex cursor-pointer select-none w-full',
'relative inline-flex cursor-pointer select-none',
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid',
@@ -153,7 +167,7 @@ const pt = computed(() => ({
},
header: () => ({
class:
hasSearchBox || showSelectedCount || hasClearButton ? 'block' : 'hidden'
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
}),
// Overlay & list visuals unchanged
overlay:
@@ -161,9 +175,17 @@ const pt = computed(() => ({
list: {
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
},
// Option row hover tone identical
option:
'flex gap-1 items-center p-2 hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: [
'flex gap-1 items-center p-2',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Add focus/highlight state for keyboard navigation
{
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
}
]
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },

View File

@@ -10,7 +10,15 @@ const meta: Meta<typeof SearchBox> = {
argTypes: {
placeHolder: {
control: 'text'
},
showBorder: {
control: 'boolean',
description: 'Toggle border prop'
}
},
args: {
placeHolder: 'Search...',
showBorder: false
}
}
@@ -25,9 +33,23 @@ export const Default: Story = {
return { searchText, args }
},
template: `
<div>
<SearchBox v-model:="searchQuery" />
<div style="max-width: 320px;">
<SearchBox v-bind="args" v-model="searchText" />
</div>
`
})
}
export const WithBorder: Story = {
...Default,
args: {
showBorder: true
}
}
export const NoBorder: Story = {
...Default,
args: {
showBorder: false
}
}

View File

@@ -15,19 +15,20 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
const { placeHolder, hasBorder = false } = defineProps<{
const { placeHolder, showBorder = false } = defineProps<{
placeHolder?: string
hasBorder?: boolean
showBorder?: boolean
}>()
const searchQuery = defineModel<string>('')
// defineModel without arguments uses 'modelValue' as the prop name
const searchQuery = defineModel<string>()
const wrapperStyle = computed(() => {
return hasBorder
return showBorder
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
})
const iconColorStyle = computed(() => {
return !hasBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
return !showBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
})
</script>

View File

@@ -4,12 +4,24 @@ import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
// SingleSelect already includes options prop, so no need to extend
const meta: Meta<typeof SingleSelect> = {
title: 'Components/Input/SingleSelect',
component: SingleSelect,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' }
label: { control: 'text' },
options: { control: 'object' }
},
args: {
label: 'Sorting Type',
options: [
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'Oldest', value: 'oldest' },
{ name: 'A → Z', value: 'az' },
{ name: 'Z → A', value: 'za' }
]
}
}
@@ -29,19 +41,18 @@ export const Default: Story = {
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const options = sampleOptions
const options = args.options || sampleOptions
return { selected, options, args }
},
template: `
<div>
<SingleSelect v-model="selected" :options="options" :label="args.label || 'Sorting Type'" />
<SingleSelect v-model="selected" :options="options" :label="args.label" />
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
</div>
</div>
`
}),
args: { label: 'Sorting Type' }
})
}
export const WithIcon: Story = {

View File

@@ -1,58 +1,73 @@
<template>
<div class="relative inline-flex items-center">
<Select
v-model="selectedItem"
:options="options"
option-label="name"
option-value="value"
unstyled
:placeholder="label"
:pt="pt"
>
<!-- Trigger value -->
<template #value="slotProps">
<div class="flex items-center gap-2 text-sm">
<slot name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-zinc-700 dark-theme:text-gray-200"
>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
{{ label }}
</span>
</div>
</template>
<!--
Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name
-->
<Select
v-model="selectedItem"
v-bind="$attrs"
:options="options"
option-label="name"
option-value="value"
unstyled
:pt="pt"
>
<!-- Trigger value -->
<template #value="slotProps">
<div class="flex items-center gap-2 text-sm">
<slot name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-zinc-700 dark-theme:text-gray-200"
>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
{{ label }}
</span>
</div>
</template>
<!-- Trigger caret -->
<template #dropdownicon>
<i-lucide:chevron-down
class="text-base text-neutral-400 dark-theme:text-gray-300"
<!-- Trigger caret -->
<template #dropdownicon>
<i-lucide:chevron-down
class="text-base text-neutral-400 dark-theme:text-gray-300"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div class="flex items-center justify-between gap-3 w-full">
<span class="truncate">{{ option.name }}</span>
<i-lucide:check
v-if="selected"
class="text-neutral-900 dark-theme:text-white"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div class="flex items-center justify-between gap-3 w-full">
<span class="truncate">{{ option.name }}</span>
<i-lucide:check
v-if="selected"
class="text-neutral-900 dark-theme:text-white"
/>
</div>
</template>
</Select>
</div>
</div>
</template>
</Select>
</template>
<script setup lang="ts">
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
import { computed } from 'vue'
defineOptions({
inheritAttrs: false
})
const { label, options } = defineProps<{
label?: string
options: {
/**
* Required for displaying the selected item's label.
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: {
name: string
value: string
}[]
@@ -60,8 +75,14 @@ const { label, options } = defineProps<{
const selectedItem = defineModel<string | null>({ required: true })
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
* only the raw value. We need this to show the correct text when an item is selected.
*/
const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val)
return found ? found.name : label ?? ''
}
@@ -77,7 +98,7 @@ const pt = computed(() => ({
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
class: [
// container
'relative inline-flex w-full cursor-pointer select-none items-center',
'relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-md',
'bg-transparent text-neutral dark-theme:text-white',
@@ -115,7 +136,9 @@ const pt = computed(() => ({
'flex items-center justify-between gap-3 px-3 py-2',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Selected state + check icon
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected }
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
// Add focus state for keyboard navigation
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.focused }
]
}),
optionLabel: {

View File

@@ -41,11 +41,13 @@ afterAll(() => {
})
// Mock the useCurrentUser composable
const mockHandleSignOut = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
userPhotoUrl: 'https://example.com/avatar.jpg',
userDisplayName: 'Test User',
userEmail: 'test@example.com'
userEmail: 'test@example.com',
handleSignOut: mockHandleSignOut
}))
}))
@@ -155,8 +157,8 @@ describe('CurrentUserPopover', () => {
// Click the logout button
await logoutButton.trigger('click')
// Verify logout was called
expect(mockLogout).toHaveBeenCalled()
// Verify handleSignOut was called
expect(mockHandleSignOut).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()

View File

@@ -88,7 +88,8 @@ const emit = defineEmits<{
close: []
}>()
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
@@ -103,7 +104,7 @@ const handleTopUp = () => {
}
const handleLogout = async () => {
await authActions.logout()
await handleSignOut()
emit('close')
}

View File

@@ -12,7 +12,7 @@
</template>
<template #header>
<SearchBox v-model:="searchQuery" class="max-w-[384px]" />
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
</template>
<template #header-right-area>
@@ -59,12 +59,13 @@
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
v-model:search-query="searchText"
class="w-[250px]"
:has-search-box="true"
:show-selected-count="true"
:has-clear-button="true"
label="Select Frameworks"
:options="frameworkOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
/>
<MultiSelect
v-model="selectedProjects"
@@ -135,7 +136,7 @@
</template>
<script setup lang="ts">
import { provide, ref } from 'vue'
import { provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
@@ -201,9 +202,18 @@ const { onClose } = defineProps<{
provide(OnCloseKey, onClose)
const searchQuery = ref<string>('')
const searchText = ref<string>('')
const selectedFrameworks = ref([])
const selectedProjects = ref([])
const selectedSort = ref<string>('popular')
const selectedNavItem = ref<string | null>('installed')
watch(searchText, (newQuery) => {
console.log('searchText:', searchText.value, newQuery)
})
watch(searchQuery, (newQuery) => {
console.log('searchQuery:', searchQuery.value, newQuery)
})
</script>

View File

@@ -240,6 +240,9 @@ const createStoryTemplate = (args: StoryArgs) => ({
v-model="selectedFrameworks"
label="Select Frameworks"
:options="frameworkOptions"
:has-search-box="true"
:show-selected-count="true"
:has-clear-button="true"
/>
<MultiSelect
v-model="selectedProjects"

View File

@@ -1,5 +1,8 @@
import { computed } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -8,6 +11,8 @@ export const useCurrentUser = () => {
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const apiKeyStore = useApiKeyAuthStore()
const dialogService = useDialogService()
const { deleteAccount } = useFirebaseAuthActions()
const firebaseUser = computed(() => authStore.currentUser)
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
@@ -85,6 +90,18 @@ export const useCurrentUser = () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}
const handleDeleteAccount = async () => {
const confirmed = await dialogService.confirm({
title: t('auth.deleteAccount.confirmTitle'),
message: t('auth.deleteAccount.confirmMessage'),
type: 'delete'
})
if (confirmed) {
await deleteAccount()
}
}
return {
loading: authStore.loading,
isLoggedIn,
@@ -96,6 +113,7 @@ export const useCurrentUser = () => {
providerName,
providerIcon,
handleSignOut,
handleSignIn
handleSignIn,
handleDeleteAccount
}
}

View File

@@ -135,6 +135,16 @@ export const useFirebaseAuthActions = () => {
reportError
)
const deleteAccount = wrapWithErrorHandlingAsync(async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
}, reportError)
return {
logout,
sendPasswordReset,
@@ -146,6 +156,7 @@ export const useFirebaseAuthActions = () => {
signInWithEmail,
signUpWithEmail,
updatePassword,
deleteAccount,
accessError
}
}

View File

@@ -1,4 +1,4 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
/**
@@ -179,6 +179,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const numImagesWidget = node.widgets?.find(
(w) => w.name === 'num_images'
) as IComboWidget
const characterInput = node.inputs?.find(
(i) => i.name === 'character_image'
) as INodeInputSlot
const hasCharacter =
typeof characterInput?.link !== 'undefined' &&
characterInput.link != null
if (!renderingSpeedWidget)
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
@@ -188,11 +194,23 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const renderingSpeed = String(renderingSpeedWidget.value)
if (renderingSpeed.toLowerCase().includes('quality')) {
basePrice = 0.09
} else if (renderingSpeed.toLowerCase().includes('balanced')) {
basePrice = 0.06
if (hasCharacter) {
basePrice = 0.2
} else {
basePrice = 0.09
}
} else if (renderingSpeed.toLowerCase().includes('default')) {
if (hasCharacter) {
basePrice = 0.15
} else {
basePrice = 0.06
}
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
basePrice = 0.03
if (hasCharacter) {
basePrice = 0.1
} else {
basePrice = 0.03
}
}
const totalCost = (basePrice * numImages).toFixed(2)
@@ -395,7 +413,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
// Same pricing matrix as KlingTextToVideoNode
if (modeValue.includes('v2-master')) {
if (modeValue.includes('v2-1')) {
if (modeValue.includes('10s')) {
return '$0.98/Run' // pro, 10s
}
return '$0.49/Run' // pro, 5s default
} else if (modeValue.includes('v2-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run'
}
@@ -1462,7 +1485,7 @@ export const useNodePricing = () => {
OpenAIGPTImage1: ['quality', 'n'],
IdeogramV1: ['num_images', 'turbo'],
IdeogramV2: ['num_images', 'turbo'],
IdeogramV3: ['rendering_speed', 'num_images'],
IdeogramV3: ['rendering_speed', 'num_images', 'character_image'],
FluxProKontextProNode: [],
FluxProKontextMaxNode: [],
VeoVideoGenerationNode: ['duration_seconds'],

View File

@@ -75,6 +75,29 @@ export const useComputedWithWidgetWatch = (
}
})
})
if (widgetNames && widgetNames.length > widgetsToObserve.length) {
//Inputs have been included
const indexesToObserve = widgetNames
.map((name) =>
widgetsToObserve.some((w) => w.name == name)
? -1
: node.inputs.findIndex((i) => i.name == name)
)
.filter((i) => i >= 0)
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
(_type: unknown, index: number, isConnected: boolean) => {
if (!indexesToObserve.includes(index)) return
widgetValues.value = {
...widgetValues.value,
[indexesToObserve[index]]: isConnected
}
if (triggerCanvasRedraw) {
node.graph?.setDirtyCanvas(true, true)
}
}
)
}
}
// Returns a function that creates a computed that responds to widget changes.

View File

@@ -1,4 +1,4 @@
import ModelSelector from '@/components/widget/ModelSelector.vue'
import SampleModelSelector from '@/components/widget/SampleModelSelector.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -15,7 +15,7 @@ export const useModelSelectorDialog = () => {
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ModelSelector,
component: SampleModelSelector,
props: {
onClose: hide
}

View File

@@ -14,6 +14,7 @@ export const CORE_MENU_COMMANDS = [
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[
['Edit'],
[

View File

@@ -1,3 +1,4 @@
import QuickLRU from '@alloc/quick-lru'
import { debounce } from 'es-toolkit/compat'
import _ from 'es-toolkit/compat'
@@ -9,6 +10,7 @@ import { ComfyApp } from '../../scripts/app'
import { $el, ComfyDialog } from '../../scripts/ui'
import { getStorageValue, setStorageValue } from '../../scripts/utils'
import { hexToRgb } from '../../utils/colorUtil'
import { parseToRgb } from '../../utils/colorUtil'
import { ClipspaceDialog } from './clipspace'
import {
imageLayerFilenamesByTimestamp,
@@ -811,7 +813,7 @@ interface Offset {
y: number
}
export interface Brush {
interface Brush {
type: BrushShape
size: number
opacity: number
@@ -2049,9 +2051,16 @@ class BrushTool {
rgbCtx: CanvasRenderingContext2D | null = null
initialDraw: boolean = true
private static brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
maxSize: 8 // Reasonable limit for brush texture variations?
})
brushStrokeCanvas: HTMLCanvasElement | null = null
brushStrokeCtx: CanvasRenderingContext2D | null = null
private static readonly SMOOTHING_MAX_STEPS = 30
private static readonly SMOOTHING_MIN_STEPS = 2
//brush adjustment
isBrushAdjusting: boolean = false
brushPreviewGradient: HTMLElement | null = null
@@ -2254,6 +2263,10 @@ class BrushTool {
}
}
private clampSmoothingPrecision(value: number): number {
return Math.min(Math.max(value, 1), 100)
}
private drawWithBetterSmoothing(point: Point) {
// Add current point to the smoothing array
if (!this.smoothingCordsArray) {
@@ -2285,9 +2298,21 @@ class BrushTool {
totalLength += Math.sqrt(dx * dx + dy * dy)
}
const distanceBetweenPoints =
(this.brushSettings.size / this.brushSettings.smoothingPrecision) * 6
const stepNr = Math.ceil(totalLength / distanceBetweenPoints)
const maxSteps = BrushTool.SMOOTHING_MAX_STEPS
const minSteps = BrushTool.SMOOTHING_MIN_STEPS
const smoothing = this.clampSmoothingPrecision(
this.brushSettings.smoothingPrecision
)
const normalizedSmoothing = (smoothing - 1) / 99 // Convert to 0-1 range
// Optionality to use exponential curve
const stepNr = Math.round(
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
)
// Calculate step distance capped by brush size
const distanceBetweenPoints = totalLength / stepNr
let interpolatedPoints = points
@@ -2435,101 +2460,205 @@ class BrushTool {
const hardness = brushSettings.hardness
const x = point.x
const y = point.y
// Extend the gradient radius beyond the brush size
const extendedSize = size * (2 - hardness)
const brushRadius = size
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
const currentTool = await this.messageBroker.pull('currentTool')
// handle paint pen
// Helper function to get or create cached brush texture
const getCachedBrushTexture = (
radius: number,
hardness: number,
color: string,
opacity: number
): HTMLCanvasElement => {
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
if (BrushTool.brushTextureCache.has(cacheKey)) {
return BrushTool.brushTextureCache.get(cacheKey)!
}
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')!
const size = radius * 2
tempCanvas.width = size
tempCanvas.height = size
const centerX = size / 2
const centerY = size / 2
const hardRadius = radius * hardness
const imageData = tempCtx.createImageData(size, size)
const data = imageData.data
const { r, g, b } = parseToRgb(color)
// Pre-calculate values to avoid repeated computations
const fadeRange = radius - hardRadius
for (let y = 0; y < size; y++) {
const dy = y - centerY
for (let x = 0; x < size; x++) {
const dx = x - centerX
const index = (y * size + x) * 4
// Calculate square distance (Chebyshev distance)
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
let pixelOpacity = 0
if (distFromEdge <= hardRadius) {
pixelOpacity = opacity
} else if (distFromEdge <= radius) {
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
pixelOpacity = opacity * (1 - fadeProgress)
}
data[index] = r
data[index + 1] = g
data[index + 2] = b
data[index + 3] = pixelOpacity * 255
}
}
tempCtx.putImageData(imageData, 0, 0)
// Cache the texture
BrushTool.brushTextureCache.set(cacheKey, tempCanvas)
return tempCanvas
}
// RGB brush logic
if (
this.activeLayer === 'rgb' &&
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
) {
const rgbaColor = this.formatRgba(this.rgbColor, opacity)
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
if (hardness === 1) {
gradient.addColorStop(0, rgbaColor)
gradient.addColorStop(
1,
this.formatRgba(this.rgbColor, brushSettingsSliderOpacity)
if (brushType === BrushShape.Rect && hardness < 1) {
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
rgbaColor,
opacity
)
} else {
gradient.addColorStop(0, rgbaColor)
gradient.addColorStop(hardness, rgbaColor)
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
rgbCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
// For max hardness, use solid fill to avoid anti-aliasing
if (hardness === 1) {
rgbCtx.fillStyle = rgbaColor
rgbCtx.beginPath()
if (brushType === BrushShape.Rect) {
rgbCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
rgbCtx.fill()
return
}
// For soft brushes, use gradient
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
gradient.addColorStop(0, rgbaColor)
gradient.addColorStop(
hardness,
this.formatRgba(this.rgbColor, opacity * 0.5)
)
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
rgbCtx.fillStyle = gradient
rgbCtx.beginPath()
if (brushType === BrushShape.Rect) {
rgbCtx.rect(
x - extendedSize,
y - extendedSize,
extendedSize * 2,
extendedSize * 2
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
rgbCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
rgbCtx.fill()
return
}
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
// Mask brush logic
if (brushType === BrushShape.Rect && hardness < 1) {
const baseColor = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
baseColor,
opacity
)
maskCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
// For max hardness, use solid fill to avoid anti-aliasing
if (hardness === 1) {
const solidColor = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
maskCtx.fillStyle = solidColor
maskCtx.beginPath()
if (brushType === BrushShape.Rect) {
maskCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
maskCtx.fill()
return
}
// For soft brushes, use gradient
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
if (isErasing) {
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
} else {
gradient.addColorStop(
0,
isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
)
gradient.addColorStop(
hardness,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity * 0.5})`
)
gradient.addColorStop(
1,
isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
)
} else {
let softness = 1 - hardness
let innerStop = Math.max(0, hardness - softness)
let outerStop = size / extendedSize
if (isErasing) {
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
gradient.addColorStop(innerStop, `rgba(255, 255, 255, ${opacity})`)
gradient.addColorStop(outerStop, `rgba(255, 255, 255, ${opacity / 2})`)
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
} else {
gradient.addColorStop(
0,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
)
gradient.addColorStop(
innerStop,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
)
gradient.addColorStop(
outerStop,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity / 2})`
)
gradient.addColorStop(
1,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
)
}
}
maskCtx.fillStyle = gradient
maskCtx.beginPath()
if (brushType === BrushShape.Rect) {
maskCtx.rect(
x - extendedSize,
y - extendedSize,
extendedSize * 2,
extendedSize * 2
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
maskCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
maskCtx.fill()
}
@@ -4185,30 +4314,35 @@ class UIManager {
const centerY = cursorPoint.y + pan_offset.y
const brush = this.brush
const hardness = brushSettings.hardness
const extendedSize = brushSettings.size * (2 - hardness) * 2 * zoom_ratio
// Now that brush size is constant, preview is simple
const brushRadius = brushSettings.size * zoom_ratio
const previewSize = brushRadius * 2
this.brushSizeSlider.value = String(brushSettings.size)
this.brushHardnessSlider.value = String(hardness)
brush.style.width = extendedSize + 'px'
brush.style.height = extendedSize + 'px'
brush.style.left = centerX - extendedSize / 2 + 'px'
brush.style.top = centerY - extendedSize / 2 + 'px'
brush.style.width = previewSize + 'px'
brush.style.height = previewSize + 'px'
brush.style.left = centerX - brushRadius + 'px'
brush.style.top = centerY - brushRadius + 'px'
if (hardness === 1) {
this.brushPreviewGradient.style.background = 'rgba(255, 0, 0, 0.5)'
return
}
const opacityStop = hardness / 4 + 0.25
// Simplified gradient - hardness controls where the fade starts
const midStop = hardness * 100
const outerStop = 100
this.brushPreviewGradient.style.background = `
radial-gradient(
circle,
rgba(255, 0, 0, 0.5) 0%,
rgba(255, 0, 0, ${opacityStop}) ${hardness * 100}%,
rgba(255, 0, 0, 0) 100%
)
radial-gradient(
circle,
rgba(255, 0, 0, 0.5) 0%,
rgba(255, 0, 0, 0.25) ${midStop}%,
rgba(255, 0, 0, 0) ${outerStop}%
)
`
}

View File

@@ -141,7 +141,8 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
*/
resolveInput(
slot: number,
visited = new Set<string>()
visited = new Set<string>(),
type?: ISlotType
): ResolvedInput | undefined {
const uniqueId = `${this.subgraphNode?.subgraph.id}:${this.node.id}[I]${slot}`
if (visited.has(uniqueId)) {
@@ -232,7 +233,11 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
`No output node DTO found for id [${outputNodeExecutionId}]`
)
return outputNodeDto.resolveOutput(link.origin_slot, input.type, visited)
return outputNodeDto.resolveOutput(
link.origin_slot,
type ?? input.type,
visited
)
}
/**
@@ -284,7 +289,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
// Upstreamed: Other virtual nodes are bypassed using the same input/output index (slots must match)
if (node.isVirtualNode) {
if (this.inputs.at(slot)) return this.resolveInput(slot, visited)
if (this.inputs.at(slot)) return this.resolveInput(slot, visited, type)
// Fallback check for nodes performing link redirection
const virtualLink = this.node.getInputLink(slot)

View File

@@ -1675,6 +1675,15 @@
"passwordUpdate": {
"success": "Password Updated",
"successDetail": "Your password has been updated successfully"
},
"deleteAccount": {
"deleteAccount": "Delete Account",
"confirmTitle": "Delete Account",
"confirmMessage": "Are you sure you want to delete your account? This action cannot be undone and will permanently remove all your data.",
"confirm": "Delete Account",
"cancel": "Cancel",
"success": "Account Deleted",
"successDetail": "Your account has been successfully deleted."
}
},
"validation": {

View File

@@ -27,6 +27,15 @@
"title": "Clave API",
"whitelistInfo": "Acerca de los sitios no incluidos en la lista blanca"
},
"deleteAccount": {
"cancel": "Cancelar",
"confirm": "Eliminar cuenta",
"confirmMessage": "¿Estás seguro de que deseas eliminar tu cuenta? Esta acción no se puede deshacer y eliminará permanentemente todos tus datos.",
"confirmTitle": "Eliminar cuenta",
"deleteAccount": "Eliminar cuenta",
"success": "Cuenta eliminada",
"successDetail": "Tu cuenta ha sido eliminada exitosamente."
},
"login": {
"andText": "y",
"confirmPasswordLabel": "Confirmar contraseña",
@@ -342,6 +351,7 @@
"micPermissionDenied": "Permiso de micrófono denegado",
"migrate": "Migrar",
"missing": "Faltante",
"moreWorkflows": "Más flujos de trabajo",
"name": "Nombre",
"newFolder": "Nueva carpeta",
"next": "Siguiente",

View File

@@ -27,6 +27,15 @@
"title": "Clé API",
"whitelistInfo": "À propos des sites non autorisés"
},
"deleteAccount": {
"cancel": "Annuler",
"confirm": "Supprimer le compte",
"confirmMessage": "Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible et supprimera définitivement toutes vos données.",
"confirmTitle": "Supprimer le compte",
"deleteAccount": "Supprimer le compte",
"success": "Compte supprimé",
"successDetail": "Votre compte a été supprimé avec succès."
},
"login": {
"andText": "et",
"confirmPasswordLabel": "Confirmer le mot de passe",
@@ -342,6 +351,7 @@
"micPermissionDenied": "Permission du microphone refusée",
"migrate": "Migrer",
"missing": "Manquant",
"moreWorkflows": "Plus de workflows",
"name": "Nom",
"newFolder": "Nouveau dossier",
"next": "Suivant",

View File

@@ -27,6 +27,15 @@
"title": "APIキー",
"whitelistInfo": "ホワイトリストに登録されていないサイトについて"
},
"deleteAccount": {
"cancel": "キャンセル",
"confirm": "アカウントを削除",
"confirmMessage": "本当にアカウントを削除しますか?この操作は元に戻せず、すべてのデータが完全に削除されます。",
"confirmTitle": "アカウントを削除",
"deleteAccount": "アカウントを削除",
"success": "アカウントが削除されました",
"successDetail": "アカウントは正常に削除されました。"
},
"login": {
"andText": "および",
"confirmPasswordLabel": "パスワードの確認",
@@ -342,6 +351,7 @@
"micPermissionDenied": "マイクの許可が拒否されました",
"migrate": "移行する",
"missing": "不足している",
"moreWorkflows": "さらに多くのワークフロー",
"name": "名前",
"newFolder": "新しいフォルダー",
"next": "次へ",

View File

@@ -27,6 +27,15 @@
"title": "API 키",
"whitelistInfo": "비허용 사이트에 대하여"
},
"deleteAccount": {
"cancel": "취소",
"confirm": "계정 삭제",
"confirmMessage": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며 모든 데이터가 영구적으로 삭제됩니다.",
"confirmTitle": "계정 삭제",
"deleteAccount": "계정 삭제",
"success": "계정이 삭제되었습니다",
"successDetail": "계정이 성공적으로 삭제되었습니다."
},
"login": {
"andText": "및",
"confirmPasswordLabel": "비밀번호 확인",
@@ -342,6 +351,7 @@
"micPermissionDenied": "마이크 권한이 거부되었습니다",
"migrate": "이전(migrate)",
"missing": "누락됨",
"moreWorkflows": "더 많은 워크플로우",
"name": "이름",
"newFolder": "새 폴더",
"next": "다음",

View File

@@ -27,6 +27,15 @@
"title": "API-ключ",
"whitelistInfo": "О не включённых в белый список сайтах"
},
"deleteAccount": {
"cancel": "Отмена",
"confirm": "Удалить аккаунт",
"confirmMessage": "Вы уверены, что хотите удалить свой аккаунт? Это действие необратимо и приведёт к безвозвратному удалению всех ваших данных.",
"confirmTitle": "Удалить аккаунт",
"deleteAccount": "Удалить аккаунт",
"success": "Аккаунт удалён",
"successDetail": "Ваш аккаунт был успешно удалён."
},
"login": {
"andText": "и",
"confirmPasswordLabel": "Подтвердите пароль",
@@ -342,6 +351,7 @@
"micPermissionDenied": "Доступ к микрофону запрещён",
"migrate": "Мигрировать",
"missing": "Отсутствует",
"moreWorkflows": "Больше рабочих процессов",
"name": "Имя",
"newFolder": "Новая папка",
"next": "Далее",

View File

@@ -27,6 +27,15 @@
"title": "API 金鑰",
"whitelistInfo": "關於未列入白名單的網站"
},
"deleteAccount": {
"cancel": "取消",
"confirm": "刪除帳號",
"confirmMessage": "您確定要刪除您的帳號嗎?此操作無法復原,且將永久移除您所有的資料。",
"confirmTitle": "刪除帳號",
"deleteAccount": "刪除帳號",
"success": "帳號已刪除",
"successDetail": "您的帳號已成功刪除。"
},
"login": {
"andText": "以及",
"confirmPasswordLabel": "確認密碼",
@@ -342,6 +351,7 @@
"micPermissionDenied": "麥克風權限被拒絕",
"migrate": "遷移",
"missing": "缺少",
"moreWorkflows": "更多工作流程",
"name": "名稱",
"newFolder": "新資料夾",
"next": "下一步",

View File

@@ -27,6 +27,15 @@
"title": "API 密钥",
"whitelistInfo": "关于非白名单网站"
},
"deleteAccount": {
"cancel": "取消",
"confirm": "删除账户",
"confirmMessage": "您确定要删除您的账户吗?此操作无法撤销,并且会永久删除您的所有数据。",
"confirmTitle": "删除账户",
"deleteAccount": "删除账户",
"success": "账户已删除",
"successDetail": "您的账户已成功删除。"
},
"login": {
"andText": "和",
"confirmPasswordLabel": "确认密码",
@@ -84,9 +93,9 @@
},
"breadcrumbsMenu": {
"clearWorkflow": "清除工作流程",
"deleteWorkflow": "除工作流程",
"duplicate": "複製",
"enterNewName": "入新名"
"deleteWorkflow": "除工作流程",
"duplicate": "复制",
"enterNewName": "入新名"
},
"chatHistory": {
"cancelEdit": "取消",
@@ -278,7 +287,6 @@
"color": "颜色",
"comingSoon": "即将推出",
"command": "指令",
"commandProhibited": "命令 {command} 被禁止。请联系管理员获取更多信息。",
"community": "社区",
"completed": "已完成",
"confirm": "确认",
@@ -299,8 +307,9 @@
"devices": "设备",
"disableAll": "禁用全部",
"disabling": "禁用中",
"dismiss": "關閉",
"dismiss": "关闭",
"download": "下载",
"duplicate": "复制",
"edit": "编辑",
"empty": "空",
"enableAll": "启用全部",
@@ -313,9 +322,8 @@
"feedback": "反馈",
"filter": "过滤",
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 或更高版本。",
"goToNode": "转到节点",
"help": "帮助",
"icon": "图标",
@@ -342,6 +350,7 @@
"micPermissionDenied": "麦克风权限被拒绝",
"migrate": "迁移",
"missing": "缺失",
"moreWorkflows": "更多工作流",
"name": "名称",
"newFolder": "新文件夹",
"next": "下一个",
@@ -404,18 +413,23 @@
"usageHint": "使用提示",
"user": "用户",
"versionMismatchWarning": "版本相容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新明。",
"versionMismatchWarningMessage": "{warning}{detail} 请参阅 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新明。",
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
},
"graphCanvasMenu": {
"fitView": "适应视图",
"focusMode": "专注模式",
"hand": "拖拽",
"hideLinks": "隐藏链接",
"panMode": "平移模式",
"resetView": "重置视图",
"select": "选择",
"selectMode": "选择模式",
"toggleLinkVisibility": "切换连线可见性",
"toggleMinimap": "切小地",
"showLinks": "显示链接",
"toggleMinimap": "切小地",
"zoomIn": "放大",
"zoomOptions": "缩放选项",
"zoomOut": "缩小"
},
"groupNode": {
@@ -573,6 +587,10 @@
"applyingTexture": "应用纹理中...",
"backgroundColor": "背景颜色",
"camera": "摄影机",
"cameraType": {
"orthographic": "正交",
"perspective": "透视"
},
"clearRecording": "清除录制",
"edgeThreshold": "边缘阈值",
"export": "导出",
@@ -593,6 +611,7 @@
"wireframe": "线框"
},
"model": "模型",
"openIn3DViewer": "在 3D 查看器中打开",
"previewOutput": "预览输出",
"removeBackgroundImage": "移除背景图片",
"resizeNodeMatchOutput": "调整节点以匹配输出",
@@ -603,8 +622,22 @@
"switchCamera": "切换摄影机类型",
"switchingMaterialMode": "切换材质模式中...",
"upDirection": "上方向",
"upDirections": {
"original": "原始"
},
"uploadBackgroundImage": "上传背景图片",
"uploadTexture": "上传纹理"
"uploadTexture": "上传纹理",
"viewer": {
"apply": "应用",
"cameraSettings": "相机设置",
"cameraType": "相机类型",
"cancel": "取消",
"exportSettings": "导出设置",
"lightSettings": "灯光设置",
"modelSettings": "模型设置",
"sceneSettings": "场景设置",
"title": "3D 查看器(测试版)"
}
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "需要 ComfyUI {version}",
@@ -651,9 +684,6 @@
"installationQueue": "安装队列",
"lastUpdated": "最后更新",
"latestVersion": "最新",
"legacyManagerUI": "使用旧版UI",
"legacyManagerUIDescription": "要使用旧版的管理器UI请启动ComfyUI并使用 --enable-manager-legacy-ui",
"legacyMenuNotAvailable": "在此版本的ComfyUI中不提供旧版的管理器菜单。请使用新的管理器菜单。",
"license": "许可证",
"loadingVersions": "正在加载版本...",
"nightlyVersion": "每夜",
@@ -727,7 +757,7 @@
"disabled": "禁用",
"disabledTooltip": "工作流将不会自动执行",
"execute": "执行",
"help": "明",
"help": "明",
"hideMenu": "隐藏菜单",
"instant": "实时",
"instantTooltip": "工作流将会在生成完成后立即执行",
@@ -736,14 +766,15 @@
"manageExtensions": "管理擴充功能",
"onChange": "更改时",
"onChangeTooltip": "一旦进行更改,工作流将添加到执行队列",
"queue": "队列面板",
"refresh": "刷新节点",
"resetView": "重置视图",
"run": "运行",
"runWorkflow": "运行工作流程Shift排在前面",
"runWorkflowFront": "运行工作流程(排在前面)",
"settings": "定",
"settings": "定",
"showMenu": "显示菜单",
"theme": "主",
"theme": "主",
"toggleBottomPanel": "底部面板"
},
"menuLabels": {
@@ -751,10 +782,8 @@
"Bottom Panel": "底部面板",
"Browse Templates": "浏览模板",
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
"Canvas Toggle Link Visibility": "切换连线可见性",
"Canvas Performance": "画布性能",
"Canvas Toggle Lock": "切换视图锁定",
"Canvas Toggle Minimap": "畫布切換小地圖",
"Check for Custom Node Updates": "檢查自訂節點更新",
"Check for Updates": "检查更新",
"Clear Pending Tasks": "清除待处理任务",
"Clear Workflow": "清除工作流",
@@ -768,27 +797,29 @@
"Contact Support": "联系支持",
"Convert Selection to Subgraph": "将选中内容转换为子图",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes (Legacy)": "自訂節點(舊版)",
"Custom Nodes Manager": "自定义节点管理器",
"Decrease Brush Size in MaskEditor": "在 MaskEditor 中減小筆刷大小",
"Decrease Brush Size in MaskEditor": "在 MaskEditor 中减小笔刷大小",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
"Edit": "编辑",
"Exit Subgraph": "退出子图",
"Export": "导出",
"Export (API)": "导出 (API)",
"File": "文件",
"Fit Group To Contents": "适应组内容",
"Fit view to selected nodes": "适应视图到选中节点",
"Focus Mode": "专注模式",
"Give Feedback": "提供反馈",
"Group Selected Nodes": "将选中节点转换为组节点",
"Help": "帮助",
"Increase Brush Size in MaskEditor": "在 MaskEditor 中增大筆刷大小",
"Install Missing Custom Nodes": "安裝缺少的自訂節點",
"Help Center": "帮助中心",
"Increase Brush Size in MaskEditor": "在 MaskEditor 中增大笔刷大小",
"Interrupt": "中断",
"Load Default Workflow": "加载默认工作流",
"Lock Canvas": "锁定画布",
"Manage group nodes": "管理组节点",
"Manager": "管理器",
"Manager Menu (Legacy)": "管理選單(舊版)",
"Minimap": "小地图",
"Model Library": "模型库",
"Move Selected Nodes Down": "下移所选节点",
"Move Selected Nodes Left": "左移所选节点",
"Move Selected Nodes Right": "右移所选节点",
@@ -796,7 +827,10 @@
"Mute/Unmute Selected Nodes": "静音/取消静音选定节点",
"New": "新建",
"Next Opened Workflow": "下一个打开的工作流",
"Node Library": "节点库",
"Node Links": "节点连接",
"Open": "打开",
"Open 3D Viewer (Beta) for Selected Node": "为选中节点打开3D查看器测试版",
"Open Custom Nodes Folder": "打开自定义节点文件夹",
"Open DevTools": "打开开发者工具",
"Open Inputs Folder": "打开输入文件夹",
@@ -809,6 +843,7 @@
"Pin/Unpin Selected Items": "固定/取消固定选定项目",
"Pin/Unpin Selected Nodes": "固定/取消固定选定节点",
"Previous Opened Workflow": "上一个打开的工作流",
"Queue Panel": "队列面板",
"Queue Prompt": "执行提示词",
"Queue Prompt (Front)": "执行提示词 (优先执行)",
"Queue Selected Output Nodes": "将所选输出节点加入队列",
@@ -821,28 +856,33 @@
"Restart": "重启",
"Save": "保存",
"Save As": "另存为",
"Show Keybindings Dialog": "示快捷鍵對話框",
"Show Model Selector (Dev)": "示模型選擇器(開發用)",
"Show Keybindings Dialog": "示快捷键对话框",
"Show Model Selector (Dev)": "示模型选择器(开发用)",
"Show Settings Dialog": "显示设置对话框",
"Sign Out": "退出登录",
"Toggle Bottom Panel": "切换底部面板",
"Toggle Focus Mode": "切换专注模式",
"Toggle Essential Bottom Panel": "切换基础底部面板",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切換模型庫側邊欄",
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
"Toggle Queue Sidebar": "切換佇列側邊欄",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
"Toggle View Controls Bottom Panel": "切换视图控制底部面板",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
"Unload Models": "卸载模型",
"Unload Models and Execution Cache": "卸载模型和执行缓存",
"Workflow": "工作流",
"Unlock Canvas": "解除锁定画布",
"Unpack the selected Subgraph": "解包选中子图",
"Workflows": "工作流",
"Zoom In": "放大画面",
"Zoom Out": "缩小画面"
"Zoom Out": "缩小画面",
"Zoom to fit": "缩放以适应"
},
"minimap": {
"nodeColors": "节点颜色",
"renderBypassState": "渲染绕过状态",
"renderErrorState": "渲染错误状态",
"showGroups": "显示框架/分组",
"showLinks": "显示连接"
},
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",
@@ -1110,6 +1150,7 @@
},
"settingsCategories": {
"3D": "3D",
"3DViewer": "3D查看器",
"API Nodes": "API 节点",
"About": "关于",
"Appearance": "外观",
@@ -1161,10 +1202,31 @@
"Window": "窗口",
"Workflow": "工作流"
},
"shortcuts": {
"essentials": "常用",
"keyboardShortcuts": "键盘快捷键",
"manageShortcuts": "管理快捷键",
"noKeybinding": "无快捷键",
"subcategories": {
"node": "节点",
"panelControls": "面板控制",
"queue": "队列",
"view": "视图",
"workflow": "工作流"
},
"viewControls": "视图控制"
},
"sideToolbar": {
"browseTemplates": "浏览示例模板",
"downloads": "下载",
"helpCenter": "帮助中心",
"labels": {
"models": "模型",
"nodes": "节点",
"queue": "队列",
"templates": "模板",
"workflows": "工作流"
},
"logout": "登出",
"modelLibrary": "模型库",
"newBlankWorkflow": "创建空白工作流",
@@ -1202,6 +1264,7 @@
},
"showFlatList": "平铺结果"
},
"templates": "模板",
"workflowTab": {
"confirmDelete": "您确定要删除此工作流吗?",
"confirmDeleteTitle": "删除工作流?",
@@ -1248,6 +1311,8 @@
"Video": "视频生成",
"Video API": "视频 API"
},
"loadingMore": "正在加载更多模板...",
"searchPlaceholder": "搜索模板...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "混元3D 2.0 图生模型",
@@ -1570,6 +1635,7 @@
"failedToExportModel": "无法将模型导出为 {format}",
"failedToFetchBalance": "获取余额失败:{error}",
"failedToFetchLogs": "无法获取服务器日志",
"failedToInitializeLoad3dViewer": "初始化3D查看器失败",
"failedToInitiateCreditPurchase": "发起积分购买失败:{error}",
"failedToPurchaseCredits": "购买积分失败:{error}",
"fileLoadError": "无法在 {fileName} 中找到工作流",
@@ -1626,9 +1692,9 @@
"required": "必填"
},
"versionMismatchWarning": {
"dismiss": "關閉",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 版或更高版本。",
"dismiss": "关闭",
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 版或更高版本。",
"title": "版本相容性警告",
"updateFrontend": "更新前端"
},
@@ -1644,5 +1710,11 @@
"enterFilename": "输入文件名",
"exportWorkflow": "导出工作流",
"saveWorkflow": "保存工作流"
},
"zoomControls": {
"hideMinimap": "隐藏小地图",
"label": "缩放控制",
"showMinimap": "显示小地图",
"zoomToFit": "适合画面"
}
}

View File

@@ -8,6 +8,7 @@ import {
type UserCredential,
browserLocalPersistence,
createUserWithEmailAndPassword,
deleteUser,
onAuthStateChanged,
sendPasswordResetEmail,
setPersistence,
@@ -287,6 +288,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
await updatePassword(currentUser.value, newPassword)
}
/** Delete the current user account */
const _deleteAccount = async (): Promise<void> => {
if (!currentUser.value) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
await deleteUser(currentUser.value)
}
const addCredits = async (
requestBodyContent: CreditPurchasePayload
): Promise<CreditPurchaseResponse> => {
@@ -385,6 +394,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
accessBillingPortal,
sendPasswordReset,
updatePassword: _updatePassword,
deleteAccount: _deleteAccount,
getAuthHeader
}
})

View File

@@ -59,6 +59,59 @@ export function hexToRgb(hex: string): RGB {
return { r, g, b }
}
export function parseToRgb(color: string): RGB {
const format = identifyColorFormat(color)
if (!format) return { r: 0, g: 0, b: 0 }
const hsla = parseToHSLA(color, format)
if (!isHSLA(hsla)) return { r: 0, g: 0, b: 0 }
// Convert HSL to RGB
const h = hsla.h / 360
const s = hsla.s / 100
const l = hsla.l / 100
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
const m = l - c / 2
let r = 0,
g = 0,
b = 0
if (h < 1 / 6) {
r = c
g = x
b = 0
} else if (h < 2 / 6) {
r = x
g = c
b = 0
} else if (h < 3 / 6) {
r = 0
g = c
b = x
} else if (h < 4 / 6) {
r = 0
g = x
b = c
} else if (h < 5 / 6) {
r = x
g = 0
b = c
} else {
r = c
g = 0
b = x
}
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255)
}
}
const identifyColorFormat = (color: string): ColorFormat | null => {
if (!color) return null
if (color.startsWith('#') && (color.length === 4 || color.length === 7))