mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
4 Commits
codex/veri
...
split/plan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f9a5347d | ||
|
|
f2d632385b | ||
|
|
fa2e174d81 | ||
|
|
a898e39d20 |
@@ -65,39 +65,12 @@ reviews:
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '**/*.ts'
|
||||
instructions: |
|
||||
Treat `docs/guidance/typescript.md` as required review context.
|
||||
- path: '**/*.vue'
|
||||
instructions: |
|
||||
Treat `docs/guidance/typescript.md` and
|
||||
`docs/guidance/vue-components.md` as required review context. For
|
||||
changed components or views under `src/components/` or `src/views/`,
|
||||
also apply `docs/guidance/design-standards.md` and assess accessibility.
|
||||
- path: '**/*.stories.ts'
|
||||
instructions: |
|
||||
Treat `docs/guidance/storybook.md` as required review context.
|
||||
- path: 'src/lib/litegraph/**'
|
||||
instructions: |
|
||||
Treat `docs/adr/README.md` as required review context. For widget
|
||||
serialization changes, also read
|
||||
`docs/WIDGET_SERIALIZATION.md`.
|
||||
- path: '**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`,
|
||||
`docs/guidance/vitest.md`, and `docs/testing/vitest-patterns.md` as
|
||||
required review context for every changed Vitest test file.
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
|
||||
- path: 'src/lib/litegraph/**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`,
|
||||
`docs/guidance/vitest.md`, `docs/testing/vitest-patterns.md`, and
|
||||
`docs/testing/litegraph-testing.md` as required review context for
|
||||
every changed LiteGraph Vitest test file.
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
|
||||
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`,
|
||||
`docs/testing/vitest-patterns.md`, and `docs/guidance/playwright.md`
|
||||
as required review context for every changed Playwright test file. For
|
||||
`browser_tests/`, also read `browser_tests/README.md` and
|
||||
`browser_tests/AGENTS.md`, and apply
|
||||
`.agents/checks/playwright-e2e.md`.
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Review guidance verification', () => {
|
||||
test('shows the canvas', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,11 @@ const config: KnipConfig = {
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Pending integration in the workspace-settings stacked PRs: consumed by
|
||||
// split/auto-reload + split/allowlist (Switch) and split/member-auditing
|
||||
// + split/allowlist (Pagination); each consumer removes its entry
|
||||
'src/components/ui/switch/Switch.vue',
|
||||
'src/components/ui/pagination/Pagination.vue',
|
||||
// Marketing media tooling — adopted by pages in a follow-up PR
|
||||
'apps/website/src/components/common/SiteVideo.vue',
|
||||
'apps/website/src/utils/marketingImage.ts',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.48.1",
|
||||
"version": "1.48.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -4,67 +4,37 @@
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-pressed="grid"
|
||||
:class="
|
||||
cn(
|
||||
actionBtnClass,
|
||||
grid && 'bg-component-node-widget-background-selected'
|
||||
)
|
||||
"
|
||||
@click="grid = !grid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span>{{ $t('boundingBoxes.grid') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="cn(actionBtnClass, 'ml-auto')"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2] size-4" />
|
||||
<span>{{ $t('boundingBoxes.clearAll') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -152,6 +122,16 @@
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -167,9 +147,6 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const actionBtnClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: NodeId }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
@@ -195,8 +172,7 @@ const {
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState,
|
||||
grid
|
||||
syncState
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -11,15 +12,21 @@ import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
const {
|
||||
itemClass: itemProp,
|
||||
contentClass: contentProp,
|
||||
modal = true
|
||||
} = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
@@ -27,6 +34,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
modal?: boolean
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -43,12 +51,19 @@ const contentClass = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = useModalLiftedZIndex(open)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuRoot v-model:open="open" :modal>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
|
||||
40
src/components/common/SelectionBar.vue
Normal file
40
src/components/common/SelectionBar.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
:aria-label="deselectLabel"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
/** The "N selected" text; the caller formats it (pluralization, wording). */
|
||||
label: string
|
||||
/** Accessible label + tooltip for the deselect button. */
|
||||
deselectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deselect: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
<i class="icon-[lucide--coins]" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
|
||||
@@ -12,7 +12,7 @@ const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
// dismiss itself. These selectors cover the portaled roots so we can treat
|
||||
// interactions on them as inside.
|
||||
const REKA_PORTAL_SELECTORS =
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
|
||||
|
||||
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<i class="icon-[lucide--coins] h-full bg-amber-400" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button', { name: '+' }))
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
@@ -44,14 +44,18 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the color picker when a swatch is clicked', async () => {
|
||||
const { container } = renderRow(['#ff0000'])
|
||||
const swatch = container.querySelector('[data-index="0"]')!
|
||||
await userEvent.click(swatch)
|
||||
expect(swatch.getAttribute('data-state')).toBe('open')
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<ColorPicker
|
||||
<div
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="i"
|
||||
:model-value="hex"
|
||||
:alpha="false"
|
||||
@update:model-value="(value) => updateAt(i, value)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
</template>
|
||||
</ColorPicker>
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
@@ -29,6 +21,12 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +34,6 @@
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
@@ -44,9 +41,8 @@ const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container
|
||||
})
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
</script>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ReviewGuidanceVerification from './ReviewGuidanceVerification.vue'
|
||||
|
||||
const meta: Meta<typeof ReviewGuidanceVerification> = {
|
||||
title: 'Verification/Review Guidance',
|
||||
component: ReviewGuidanceVerification,
|
||||
args: {
|
||||
disabled: false,
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { disabled = false, loading = false } = defineProps<{
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :aria-busy="loading" :aria-disabled="disabled" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
class="icon-[lucide--coins] size-3 text-amber-400"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
|
||||
@@ -14,27 +14,20 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import ColorPickerPanel from './ColorPickerPanel.vue'
|
||||
|
||||
const { alpha = true } = defineProps<{
|
||||
defineProps<{
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
alpha?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '#000000' })
|
||||
|
||||
function readHsva(hex: string): HSVA {
|
||||
const next = hexToHsva(hex || '#000000')
|
||||
if (!alpha) next.a = 100
|
||||
return next
|
||||
}
|
||||
|
||||
const hsva = ref<HSVA>(readHsva(modelValue.value))
|
||||
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
|
||||
const displayMode = ref<'hex' | 'rgba'>('hex')
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
const current = hsvaToHex(hsva.value)
|
||||
if (newVal !== current) {
|
||||
hsva.value = readHsva(newVal)
|
||||
hsva.value = hexToHsva(newVal || '#000000')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,51 +67,49 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<slot name="trigger">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</slot>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
@@ -132,7 +123,6 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<ColorPickerPanel
|
||||
v-model:hsva="hsva"
|
||||
v-model:display-mode="displayMode"
|
||||
:alpha
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
|
||||
@@ -13,8 +13,6 @@ import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
|
||||
const { alpha = true } = defineProps<{ alpha?: boolean }>()
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
@@ -39,7 +37,6 @@ const { t } = useI18n()
|
||||
/>
|
||||
<ColorPickerSlider v-model="hsva.h" type="hue" />
|
||||
<ColorPickerSlider
|
||||
v-if="alpha"
|
||||
v-model="hsva.a"
|
||||
type="alpha"
|
||||
:hue="hsva.h"
|
||||
@@ -75,7 +72,7 @@ const { t } = useI18n()
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
|
||||
</template>
|
||||
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
|
||||
<span class="shrink-0 border-l border-border-subtle pl-1"
|
||||
>{{ hsva.a }}%</span
|
||||
>
|
||||
</div>
|
||||
|
||||
72
src/components/ui/pagination/Pagination.vue
Normal file
72
src/components/ui/pagination/Pagination.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<PaginationRoot
|
||||
:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
@update:page="(p: number) => emit('update:page', p)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<PaginationPrev as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
{{ $t('g.previous') }}
|
||||
</Button>
|
||||
</PaginationPrev>
|
||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationListItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
as-child
|
||||
>
|
||||
<Button
|
||||
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
|
||||
size="icon"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
|
||||
…
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
</PaginationList>
|
||||
<PaginationNext as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
{{ $t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
</PaginationNext>
|
||||
</div>
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot
|
||||
} from 'reka-ui'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
total,
|
||||
itemsPerPage = 10
|
||||
} = defineProps<{
|
||||
page?: number
|
||||
total: number
|
||||
itemsPerPage?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:page': [page: number] }>()
|
||||
|
||||
const ellipsisClass =
|
||||
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
|
||||
</script>
|
||||
@@ -35,7 +35,7 @@ export const searchInputSizeConfig = {
|
||||
icon: 'size-4',
|
||||
iconPos: 'left-2.5',
|
||||
inputPl: 'pl-8',
|
||||
inputText: 'text-xs',
|
||||
inputText: 'text-sm',
|
||||
clearPos: 'left-2.5'
|
||||
},
|
||||
xl: {
|
||||
|
||||
30
src/components/ui/switch/Switch.vue
Normal file
30
src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-model="checked"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-3.5' : 'translate-x-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { disabled = false } = defineProps<{ disabled?: boolean }>()
|
||||
const checked = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
17
src/components/ui/table/Table.vue
Normal file
17
src/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div :class="cn('relative w-full overflow-auto', className)">
|
||||
<table
|
||||
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableBody.vue
Normal file
13
src/components/ui/table/TableBody.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableCell.vue
Normal file
13
src/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
21
src/components/ui/table/TableHead.vue
Normal file
21
src/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<th
|
||||
scope="col"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
15
src/components/ui/table/TableHeader.vue
Normal file
15
src/components/ui/table/TableHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<thead
|
||||
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
20
src/components/ui/table/TableRow.vue
Normal file
20
src/components/ui/table/TableRow.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<tr
|
||||
:class="
|
||||
cn(
|
||||
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
16
src/components/ui/tabs/Tabs.vue
Normal file
16
src/components/ui/tabs/Tabs.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
20
src/components/ui/tabs/TabsList.vue
Normal file
20
src/components/ui/tabs/TabsList.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsList } from 'reka-ui'
|
||||
import type { TabsListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsListProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="rest"
|
||||
:class="cn('inline-flex items-center gap-4', className)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||
import type { TabsTriggerProps } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer appearance-none border-0 border-b-2 border-transparent bg-transparent px-0 pb-2 text-sm text-muted-foreground transition-colors outline-none data-[state=active]:border-base-foreground data-[state=active]:text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
@@ -14,7 +14,12 @@
|
||||
>
|
||||
<header
|
||||
data-component-id="LeftPanelHeader"
|
||||
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="leftPanelHeaderTitle" />
|
||||
<Button
|
||||
@@ -33,7 +38,12 @@
|
||||
<div class="flex flex-col overflow-hidden bg-base-background">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full items-center justify-between gap-2 px-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<Button
|
||||
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
type ContentPadding = 'default' | 'compact' | 'none'
|
||||
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg',
|
||||
leftPanelWidth = '14rem',
|
||||
contentPadding = 'default'
|
||||
contentPadding = 'default',
|
||||
headerHeightClass = 'h-18'
|
||||
} = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
leftPanelWidth?: string
|
||||
contentPadding?: ContentPadding
|
||||
headerHeightClass?: string
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
|
||||
cn(
|
||||
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
|
||||
contentPadding === 'default' && 'px-6 pt-0 pb-10',
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
|
||||
// Keep the horizontal inset but let content run to the bottom edge (it
|
||||
// clips there instead of ending above a padding gap).
|
||||
contentPadding === 'flush' && 'px-6 pt-0'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/** Subscription paused on a failed payment (`subscriptionStatus === 'paused'`). */
|
||||
isPaused: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
|
||||
@@ -147,6 +147,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
const subscriptionStatus = computed(() =>
|
||||
toValue(activeContext.value.subscriptionStatus)
|
||||
)
|
||||
const isPaused = computed(() => subscriptionStatus.value === 'paused')
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
|
||||
@@ -301,6 +302,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLegacyTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
isPaused,
|
||||
tier,
|
||||
renewalDate,
|
||||
getMaxSeats,
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('fromBoundingBoxes', () => {
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
|
||||
@@ -167,31 +167,10 @@ describe('fromBoundingBoxes', () => {
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
desc: 'd',
|
||||
palette: ['#ffffff']
|
||||
palette: ['#fff']
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes palette entries and drops invalid colors', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
metadata: {
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: ['#FF0000', '#abc', 'red', '', 123] as unknown as string[]
|
||||
}
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0].palette).toEqual([
|
||||
'#ff0000',
|
||||
'#aabbcc'
|
||||
])
|
||||
})
|
||||
|
||||
it('fills defaults when metadata is missing or partial', () => {
|
||||
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
|
||||
|
||||
@@ -202,22 +202,6 @@ function isBoundingBox(b: unknown): b is BoundingBox {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeHexColor(color: unknown): string | null {
|
||||
if (typeof color !== 'string') return null
|
||||
const hex = color.trim().toLowerCase()
|
||||
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex)
|
||||
if (short) {
|
||||
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
|
||||
}
|
||||
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null
|
||||
}
|
||||
|
||||
function normalizePalette(palette: unknown): string[] {
|
||||
return Array.isArray(palette)
|
||||
? palette.map(normalizeHexColor).filter((c): c is string => c !== null)
|
||||
: []
|
||||
}
|
||||
|
||||
export function fromBoundingBoxes(
|
||||
boxes: readonly BoundingBox[],
|
||||
width: number,
|
||||
@@ -235,7 +219,9 @@ export function fromBoundingBoxes(
|
||||
type: meta.type === 'text' ? 'text' : 'obj',
|
||||
text: typeof meta.text === 'string' ? meta.text : '',
|
||||
desc: typeof meta.desc === 'string' ? meta.desc : '',
|
||||
palette: normalizePalette(meta.palette)
|
||||
palette: Array.isArray(meta.palette)
|
||||
? meta.palette.filter((c): c is string => typeof c === 'string')
|
||||
: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,32 +8,14 @@ import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { appState, outputState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown },
|
||||
outputState: {
|
||||
outputs: undefined as unknown,
|
||||
nodeOutputs: null as { value: Record<string, unknown> } | null
|
||||
}
|
||||
const { appState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const nodeOutputs = ref<Record<string, unknown>>({})
|
||||
outputState.nodeOutputs = nodeOutputs
|
||||
return {
|
||||
useNodeOutputStore: () => ({
|
||||
nodeOutputs,
|
||||
nodePreviewImages: ref({}),
|
||||
getNodeImageUrls: () => undefined,
|
||||
getNodeOutputs: () => outputState.outputs
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
@@ -45,9 +27,6 @@ const ctx = {
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
moveTo: () => {},
|
||||
arc: () => {},
|
||||
fill: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
@@ -79,32 +58,17 @@ function makeCanvas(): HTMLCanvasElement {
|
||||
return el
|
||||
}
|
||||
|
||||
interface MockNode {
|
||||
widgets: { name: string; value: unknown }[]
|
||||
findInputSlot: (name: string) => number
|
||||
getInputNode: () => null
|
||||
isInputConnected?: () => boolean
|
||||
}
|
||||
|
||||
function makeNode(): MockNode {
|
||||
function makeNode() {
|
||||
return {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'last_incoming', value: [] }
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
}
|
||||
|
||||
const lastIncomingOf = (node: MockNode) =>
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value
|
||||
|
||||
const setLastIncomingOf = (node: MockNode, value: BoundingBox[]) => {
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value = value
|
||||
}
|
||||
|
||||
const pe = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
@@ -132,8 +96,6 @@ interface Captured extends Api {
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
const modelBoxes = (c: Captured) => c.modelValue.value
|
||||
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
@@ -166,19 +128,9 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
...over
|
||||
})
|
||||
|
||||
function makeConnectedNode(): MockNode {
|
||||
return {
|
||||
...makeNode(),
|
||||
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
|
||||
isInputConnected: () => true
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = makeNode()
|
||||
outputState.outputs = undefined
|
||||
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
void Promise.resolve().then(() => cb(0))
|
||||
return 1
|
||||
@@ -216,8 +168,8 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('discards a zero-size draw', async () => {
|
||||
@@ -225,7 +177,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onDocPointerUp(pe(10, 10))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('selects an existing region instead of drawing when clicking inside it', async () => {
|
||||
@@ -233,7 +185,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -242,7 +194,7 @@ describe('useBoundingBoxes region editing', () => {
|
||||
const c = setup([box()])
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].metadata.type).toBe('text')
|
||||
expect(c.modelValue.value[0].metadata.type).toBe('text')
|
||||
})
|
||||
|
||||
it('deletes the active region on Delete', async () => {
|
||||
@@ -253,18 +205,14 @@ describe('useBoundingBoxes region editing', () => {
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears all regions and invalidates the applied upstream input', async () => {
|
||||
const node = makeNode()
|
||||
setLastIncomingOf(node, [box()])
|
||||
appState.node = node
|
||||
it('clears all regions', async () => {
|
||||
const c = setup([box(), box({ x: 0 })])
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
expect(lastIncomingOf(node)).toEqual([])
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -278,7 +226,7 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
c.inlineEditor.value!.value = 'a label'
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
@@ -291,168 +239,6 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes incoming bboxes input', () => {
|
||||
it('adopts cached outputs on mount without overwriting existing edits', () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('does not re-apply an already applied output after a remount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
setLastIncomingOf(node, incoming)
|
||||
appState.node = node
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
})
|
||||
|
||||
it('ignores incoming output when the input is not connected', () => {
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('repopulates from the next run after clearing the canvas', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
})
|
||||
|
||||
it('does not apply output updates while the input is disconnected', async () => {
|
||||
let connected = true
|
||||
appState.node = {
|
||||
...makeConnectedNode(),
|
||||
isInputConnected: () => connected
|
||||
}
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
connected = false
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not apply incoming boxes while the user is drawing', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(50, 50))
|
||||
|
||||
outputState.outputs = {
|
||||
input_bboxes: [box({ x: 0, width: 100, height: 100 })]
|
||||
}
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
c.onDocPointerUp(pe(50, 50))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(205)
|
||||
})
|
||||
|
||||
it('applies incoming boxes when outputs stream in after mount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('re-seeds the canvas over user edits when the upstream value changes', async () => {
|
||||
const node = makeConnectedNode()
|
||||
setLastIncomingOf(node, [box({ x: 0, width: 100 })])
|
||||
appState.node = node
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
|
||||
const changed = [box({ x: 64, width: 128 })]
|
||||
outputState.outputs = { input_bboxes: changed }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)[0].width).toBe(128)
|
||||
expect(lastIncomingOf(node)).toEqual(changed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes grid snapping', () => {
|
||||
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].x).toBe(64)
|
||||
expect(modelBoxes(c)[0].width).toBe(256)
|
||||
})
|
||||
|
||||
it('does not snap when grid is disabled', async () => {
|
||||
const c = setup()
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(55, 55))
|
||||
c.onDocPointerUp(pe(55, 55))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(230)
|
||||
})
|
||||
|
||||
it('keeps the anchored edge fixed when resizing a single edge', async () => {
|
||||
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
|
||||
c.onPointerDown(pe(60, 30))
|
||||
c.onCanvasPointerMove(pe(80, 30))
|
||||
c.onDocPointerUp(pe(80, 30))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].x).toBe(51)
|
||||
})
|
||||
|
||||
it('removes a box that a resize collapses to zero size', async () => {
|
||||
const c = setup([box({ x: 64, y: 64, width: 128, height: 128 })])
|
||||
c.onPointerDown(pe(37, 25))
|
||||
c.onCanvasPointerMove(pe(14, 25))
|
||||
c.onDocPointerUp(pe(14, 25))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
it('switches to a pointer cursor over a tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { cloneDeep, isEqual } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -16,7 +15,6 @@ import type {
|
||||
Region
|
||||
} from '@/composables/boundingBoxes/boundingBoxesUtil'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
@@ -27,10 +25,6 @@ const HANDLE_PX = 8
|
||||
const DIMENSION_STEP = 16
|
||||
const BG_DIM = 0.75
|
||||
const MAX_ELEMENT_COLORS = 5
|
||||
const GRID_PX = 32
|
||||
const MAX_GRID_CELLS = 64
|
||||
const DOT_ALPHA = 0.18
|
||||
const DOT_RADIUS = 1
|
||||
|
||||
interface InlineEditorState {
|
||||
value: string
|
||||
@@ -63,7 +57,6 @@ export function useBoundingBoxes(
|
||||
const hoverTagIndex = ref<number | null>(null)
|
||||
const bgImage = ref<HTMLImageElement | null>(null)
|
||||
const inlineEditor = ref<InlineEditorState | null>(null)
|
||||
const grid = ref(true)
|
||||
|
||||
const { width: containerWidth } = useElementSize(canvasContainer)
|
||||
|
||||
@@ -103,89 +96,6 @@ export function useBoundingBoxes(
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function gridSpec() {
|
||||
const axisFraction = (size: number) =>
|
||||
Math.max(GRID_PX, Math.ceil(size / MAX_GRID_CELLS)) / size
|
||||
return {
|
||||
fx: axisFraction(widthValue.value),
|
||||
fy: axisFraction(heightValue.value)
|
||||
}
|
||||
}
|
||||
|
||||
function snapFraction(value: number, step: number) {
|
||||
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
|
||||
}
|
||||
|
||||
function snapRegion(region: Region, mode: HitMode): Region {
|
||||
if (!grid.value) return region
|
||||
const { fx, fy } = gridSpec()
|
||||
if (mode === 'move') {
|
||||
return {
|
||||
...region,
|
||||
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
|
||||
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
|
||||
}
|
||||
}
|
||||
const snapLeft =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-l' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-bl'
|
||||
const snapRight =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-r' ||
|
||||
mode === 'resize-tr' ||
|
||||
mode === 'resize-br'
|
||||
const snapTop =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-t' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-tr'
|
||||
const snapBottom =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-b' ||
|
||||
mode === 'resize-bl' ||
|
||||
mode === 'resize-br'
|
||||
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
|
||||
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
|
||||
const x2 = snapRight
|
||||
? snapFraction(region.x + region.w, fx)
|
||||
: region.x + region.w
|
||||
const y2 = snapBottom
|
||||
? snapFraction(region.y + region.h, fy)
|
||||
: region.y + region.h
|
||||
return {
|
||||
...region,
|
||||
x: x1,
|
||||
y: y1,
|
||||
w: Math.max(0, x2 - x1),
|
||||
h: Math.max(0, y2 - y1)
|
||||
}
|
||||
}
|
||||
|
||||
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
const { fx, fy } = gridSpec()
|
||||
if (fx <= 0 || fy <= 0) return
|
||||
const cols = Math.round(1 / fx)
|
||||
const rows = Math.round(1 / fy)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = DOT_ALPHA
|
||||
ctx.fillStyle = getComputedStyle(el).color
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const cx = Math.min(1, i * fx) * W
|
||||
for (let j = 0; j <= rows; j++) {
|
||||
const cy = Math.min(1, j * fy) * H
|
||||
ctx.moveTo(cx + DOT_RADIUS, cy)
|
||||
ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2)
|
||||
}
|
||||
}
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function logicalSize() {
|
||||
const el = canvasEl.value
|
||||
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
|
||||
@@ -236,8 +146,6 @@ export function useBoundingBoxes(
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
if (grid.value) drawDots(ctx, W, H)
|
||||
|
||||
const showActive = focused.value || isNodeSelected.value
|
||||
const aIdx = showActive ? activeIndex.value : -1
|
||||
const order = state.value.regions
|
||||
@@ -458,7 +366,7 @@ export function useBoundingBoxes(
|
||||
const dx = mN.x - dragStartNorm.value.x
|
||||
const dy = mN.y - dragStartNorm.value.y
|
||||
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
|
||||
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
|
||||
state.value.regions[activeIndex.value] = nb
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
@@ -467,7 +375,7 @@ export function useBoundingBoxes(
|
||||
drawing.value = false
|
||||
canvasEl.value?.releasePointerCapture?.(e.pointerId)
|
||||
const b = state.value.regions[activeIndex.value]
|
||||
if (b && (b.w < 0.005 || b.h < 0.005)) {
|
||||
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
|
||||
removeRegion(activeIndex.value)
|
||||
}
|
||||
syncState()
|
||||
@@ -602,7 +510,6 @@ export function useBoundingBoxes(
|
||||
function clearAll() {
|
||||
state.value.regions = []
|
||||
activeIndex.value = -1
|
||||
setLastIncoming([])
|
||||
syncState()
|
||||
}
|
||||
|
||||
@@ -623,23 +530,6 @@ export function useBoundingBoxes(
|
||||
watch(isNodeSelected, () => requestDraw())
|
||||
watch([widthValue, heightValue], () => syncState())
|
||||
|
||||
watch(
|
||||
litegraphNode,
|
||||
(node) => {
|
||||
const props = node?.properties as { bboxGrid?: unknown } | undefined
|
||||
if (props && typeof props.bboxGrid === 'boolean')
|
||||
grid.value = props.bboxGrid
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(grid, (enabled) => {
|
||||
const props = litegraphNode.value?.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (props) props.bboxGrid = enabled
|
||||
requestDraw()
|
||||
})
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
|
||||
const node = litegraphNode.value
|
||||
@@ -690,63 +580,10 @@ export function useBoundingBoxes(
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
function lastIncomingWidget() {
|
||||
return litegraphNode.value?.widgets?.find((w) => w.name === 'last_incoming')
|
||||
}
|
||||
|
||||
function lastIncomingValue(): BoundingBox[] {
|
||||
const value = lastIncomingWidget()?.value
|
||||
return Array.isArray(value) ? (value as BoundingBox[]) : []
|
||||
}
|
||||
|
||||
function setLastIncoming(boxes: BoundingBox[]) {
|
||||
const widget = lastIncomingWidget()
|
||||
if (!widget) return
|
||||
const next = cloneDeep(boxes)
|
||||
widget.value = next
|
||||
widget.callback?.(next)
|
||||
}
|
||||
|
||||
function applyIncomingBoxes(apply = true) {
|
||||
if (drawing.value) return
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
const slot = node.findInputSlot('bboxes')
|
||||
if (slot < 0 || !node.isInputConnected(slot)) return
|
||||
const outputs = nodeOutputStore.getNodeOutputs(node) as
|
||||
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
|
||||
| undefined
|
||||
const incoming = outputs?.input_bboxes
|
||||
if (!incoming?.length) return
|
||||
const applied = lastIncomingValue()
|
||||
if (isEqual(incoming, applied)) return
|
||||
if (!apply) {
|
||||
if (!applied.length && state.value.regions.length)
|
||||
setLastIncoming(incoming)
|
||||
return
|
||||
}
|
||||
state.value.regions = fromBoundingBoxes(
|
||||
incoming,
|
||||
widthValue.value,
|
||||
heightValue.value
|
||||
)
|
||||
activeIndex.value = state.value.regions.length ? 0 : -1
|
||||
setLastIncoming(incoming)
|
||||
syncState()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => nodeOutputStore.nodeOutputs,
|
||||
() => {
|
||||
updateBgImage()
|
||||
applyIncomingBoxes()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
|
||||
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
|
||||
|
||||
updateBgImage()
|
||||
applyIncomingBoxes(false)
|
||||
void nextTick(() => requestDraw())
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -771,7 +608,6 @@ export function useBoundingBoxes(
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState,
|
||||
grid
|
||||
syncState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref, shallowRef } from 'vue'
|
||||
|
||||
@@ -13,12 +13,17 @@ afterEach(() => {
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
|
||||
return { modelValue, container, ...api }
|
||||
const api = scope.run(() =>
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
)!
|
||||
return { modelValue, container, picker, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
const { modelValue, addColor } = setup(['#000000'])
|
||||
@@ -32,17 +37,31 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(modelValue.value).toEqual(['#a', '#c'])
|
||||
})
|
||||
|
||||
it('updates the color at an index', () => {
|
||||
const { modelValue, updateAt } = setup(['#a', '#b'])
|
||||
updateAt(1, '#123456')
|
||||
it('seeds the picker input with the clicked color before opening it', () => {
|
||||
const { picker, openPicker } = setup(['#112233'])
|
||||
const click = vi.spyOn(picker.value!, 'click')
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#112233')
|
||||
expect(click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to white when the slot is empty', () => {
|
||||
const { picker, openPicker } = setup([''])
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a', '#123456'])
|
||||
})
|
||||
|
||||
it('ignores an update that does not change the color', () => {
|
||||
const { modelValue, updateAt } = setup(['#a'])
|
||||
const before = modelValue.value
|
||||
updateAt(0, '#a')
|
||||
expect(modelValue.value).toBe(before)
|
||||
it('ignores picker input when no slot is open', () => {
|
||||
const { modelValue, onPickerInput } = setup(['#a'])
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a'])
|
||||
})
|
||||
|
||||
it('reorders via drag when the pointer crosses another swatch', () => {
|
||||
|
||||
@@ -5,16 +5,30 @@ import { ref } from 'vue'
|
||||
interface UsePaletteSwatchRowOptions {
|
||||
modelValue: Ref<string[]>
|
||||
container: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
picker: Readonly<ShallowRef<HTMLInputElement | null>>
|
||||
}
|
||||
|
||||
export function usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container
|
||||
container,
|
||||
picker
|
||||
}: UsePaletteSwatchRowOptions) {
|
||||
function updateAt(i: number, value: string) {
|
||||
if (modelValue.value[i] === value) return
|
||||
const pickerIndex = ref<number | null>(null)
|
||||
|
||||
function openPicker(i: number, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
pickerIndex.value = i
|
||||
const el = picker.value
|
||||
if (!el) return
|
||||
el.value = modelValue.value[i] || '#ffffff'
|
||||
el.click()
|
||||
}
|
||||
|
||||
function onPickerInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
if (pickerIndex.value === null) return
|
||||
const next = modelValue.value.slice()
|
||||
next[i] = value
|
||||
next[pickerIndex.value] = v
|
||||
modelValue.value = next
|
||||
}
|
||||
|
||||
@@ -91,7 +105,8 @@ export function usePaletteSwatchRow({
|
||||
})
|
||||
|
||||
return {
|
||||
updateAt,
|
||||
openPicker,
|
||||
onPickerInput,
|
||||
remove,
|
||||
addColor,
|
||||
onPointerDown
|
||||
|
||||
@@ -90,7 +90,9 @@ export function useExternalLink() {
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
comfyOrg: 'https://www.comfy.org/'
|
||||
comfyOrg: 'https://www.comfy.org/',
|
||||
teamPlanRequests:
|
||||
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
|
||||
}
|
||||
|
||||
/** Common doc paths for use with buildDocsUrl */
|
||||
|
||||
@@ -77,14 +77,6 @@ vi.mock('pinia', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: settingGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
@@ -103,9 +95,6 @@ describe('useLoad3d', () => {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '282828' : undefined
|
||||
)
|
||||
|
||||
mockNode = createMockLGraphNode({
|
||||
properties: {
|
||||
@@ -367,20 +356,6 @@ describe('useLoad3d', () => {
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should set preview mode for save-viewer nodes despite width/height widgets', async () => {
|
||||
Object.defineProperty(mockNode, 'constructor', {
|
||||
value: { comfyClass: 'Save3DAdvanced' },
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
@@ -408,37 +383,7 @@ describe('useLoad3d', () => {
|
||||
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#282828')
|
||||
})
|
||||
|
||||
it('defaults background color from the Comfy.Load3D.BackgroundColor setting', () => {
|
||||
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
reactive({ appScalePercentage: 100 }) as unknown as ReturnType<
|
||||
typeof useCanvasStore
|
||||
>
|
||||
)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '123456' : undefined
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#123456')
|
||||
})
|
||||
|
||||
it('attaches event listeners before running queued ready callbacks', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
let listenersAttachedWhenCallbackRan = false
|
||||
|
||||
composable.waitForLoad3d(() => {
|
||||
listenersAttachedWhenCallbackRan =
|
||||
vi.mocked(mockLoad3d.addEventListener!).mock.calls.length > 0
|
||||
})
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(listenersAttachedWhenCallbackRan).toBe(true)
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
|
||||
})
|
||||
|
||||
it('passes getZoomScale callback to createLoad3d', async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
@@ -119,9 +118,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const sceneConfig = ref<SceneConfig>({
|
||||
showGrid: true,
|
||||
backgroundColor: getActivePinia()
|
||||
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
|
||||
: '#282828',
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
})
|
||||
@@ -195,7 +192,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (
|
||||
isLoad3dResultViewerNode(node.constructor.comfyClass ?? '') ||
|
||||
node.constructor.comfyClass?.startsWith('Preview') ||
|
||||
!(widthWidget && heightWidget)
|
||||
) {
|
||||
@@ -252,8 +248,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
handleEvents('add')
|
||||
|
||||
const callbacks = pendingCallbacks.get(node)
|
||||
|
||||
if (callbacks && load3d) {
|
||||
@@ -269,6 +263,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d) invokeReadyCallback(callback, load3d)
|
||||
})
|
||||
}
|
||||
|
||||
handleEvents('add')
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d:', error)
|
||||
useToastStore().addAlert(
|
||||
|
||||
@@ -4,7 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
| LightConfig
|
||||
| undefined
|
||||
|
||||
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
|
||||
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
|
||||
|
||||
if (sceneConfig) {
|
||||
backgroundColor.value =
|
||||
|
||||
@@ -32,8 +32,7 @@ function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
|
||||
const widgets: MockWidget[] = [
|
||||
{ name: 'width', hidden: false, options: {} },
|
||||
{ name: 'height', hidden: false, options: {} },
|
||||
{ name: 'other', hidden: false, options: {} },
|
||||
{ name: 'last_incoming', hidden: false, options: {} }
|
||||
{ name: 'other', hidden: false, options: {} }
|
||||
]
|
||||
return {
|
||||
constructor: { comfyClass },
|
||||
@@ -74,15 +73,6 @@ describe('Comfy.CreateBoundingBoxes extension', () => {
|
||||
expect(node.widgets[0].options.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('always hides the internal last_incoming widget', () => {
|
||||
for (const connected of [true, false]) {
|
||||
const node = makeNode(connected)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.widgets[3].hidden).toBe(true)
|
||||
expect(node.widgets[3].options.hidden).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes visibility through the widget value store when present', () => {
|
||||
state.widgetState = { options: {} }
|
||||
const node = makeNode(true)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const DIMENSION_WIDGETS = new Set(['width', 'height'])
|
||||
const INTERNAL_WIDGETS = new Set(['last_incoming'])
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CreateBoundingBoxes',
|
||||
@@ -16,30 +15,20 @@ useExtensionService().registerExtension({
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
const setWidgetHidden = (
|
||||
widget: NonNullable<typeof node.widgets>[number],
|
||||
hidden: boolean
|
||||
) => {
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
}
|
||||
|
||||
const syncDimensionVisibility = () => {
|
||||
const slot = node.findInputSlot('background')
|
||||
const hidden = slot >= 0 && node.isInputConnected(slot)
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (DIMENSION_WIDGETS.has(widget.name)) setWidgetHidden(widget, hidden)
|
||||
if (!DIMENSION_WIDGETS.has(widget.name)) continue
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
|
||||
}
|
||||
|
||||
syncDimensionVisibility()
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
|
||||
@@ -143,23 +143,14 @@ async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
preview3DAdvancedExt: ExtCreated
|
||||
save3DAdvancedExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
const extByName = (name: string): ExtCreated => {
|
||||
const call = registerExtensionMock.mock.calls.find(
|
||||
(c) => (c[0] as ExtCreated).name === name
|
||||
)
|
||||
if (!call) throw new Error(`Extension ${name} was not registered`)
|
||||
return call[0] as ExtCreated
|
||||
}
|
||||
return {
|
||||
load3DExt: extByName('Comfy.Load3D'),
|
||||
preview3DExt: extByName('Comfy.Preview3D'),
|
||||
preview3DAdvancedExt: extByName('Comfy.Preview3DAdvanced'),
|
||||
save3DAdvancedExt: extByName('Comfy.Save3DAdvanced')
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
|
||||
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,15 +264,14 @@ function setupBaseMocks() {
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
|
||||
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -721,39 +711,6 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.onNodeOutputsUpdated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('restores the saved model from the output folder when opened from history', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['3d\\ComfyUI_00001.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('3d/ComfyUI_00001.glb')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001.glb',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['mesh.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
@@ -1075,50 +1032,6 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores persisted models from the output folder, not temp', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
comfyClass: 'Save3DAdvanced',
|
||||
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.glb' }
|
||||
})
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('onExecuted loads the saved file from the output folder', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['3d/ComfyUI_00002_.glb'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00002_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D scene widget serializeValue caching', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
|
||||
@@ -15,10 +15,8 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
LoadFolder,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import {
|
||||
LOAD3D_NONE_MODEL,
|
||||
@@ -50,7 +48,6 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const inputSpecLoad3D: CustomInputSpec = {
|
||||
@@ -290,11 +287,8 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
const VIEWPORT_STATE_NODES = new Set([
|
||||
'Preview3DAdvanced',
|
||||
'Save3DAdvanced',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
'PreviewPointCloud'
|
||||
])
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
@@ -685,215 +679,155 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
})
|
||||
|
||||
function applyPreview3DAdvancedResult(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d,
|
||||
result: NonNullable<Preview3DAdvancedOutput['result']>,
|
||||
loadFolder: LoadFolder,
|
||||
comfyClass: string
|
||||
): void {
|
||||
const filePath = result[0]
|
||||
if (!filePath) return
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAdvanced',
|
||||
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
const cameraState = result[1]
|
||||
const modelTransform = result[2]?.[0]
|
||||
if (!cameraState && !modelTransform) return
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to apply input camera_info / model_3d_info from ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
function createPreview3DAdvancedExtension(
|
||||
comfyClass: string,
|
||||
extensionName: string,
|
||||
loadFolder: LoadFolder
|
||||
): ComfyExtension {
|
||||
return {
|
||||
name: extensionName,
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
|
||||
|
||||
onNodeOutputsUpdated(
|
||||
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
|
||||
) {
|
||||
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const result = (output as Preview3DAdvancedOutput).result
|
||||
if (!result?.[0]) continue
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
if (!node || node.constructor.comfyClass !== comfyClass) continue
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
load3d,
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to restore camera state for Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== comfyClass) return []
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== comfyClass) return
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
const { onLoad3dReady, waitForLoad3d } = useLoad3d(node)
|
||||
|
||||
onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
|
||||
const currentLoad3d = resolveLoad3d()
|
||||
const config = new Load3DConfiguration(currentLoad3d, node.properties)
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to restore camera state for ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
const cameraState = result?.[1]
|
||||
const modelTransform = result?.[2]?.[0]
|
||||
if (cameraState || modelTransform) {
|
||||
const targetGeneration = currentLoad3d.currentLoadGeneration
|
||||
void currentLoad3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
|
||||
return
|
||||
if (cameraState) currentLoad3d.setCameraState(cameraState)
|
||||
if (modelTransform)
|
||||
currentLoad3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties[
|
||||
'Camera Config'
|
||||
] as CameraConfig | undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
if (!result?.[0]) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
resolveLoad3d(),
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Preview3DAdvanced',
|
||||
'Comfy.Preview3DAdvanced',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Save3DAdvanced',
|
||||
'Comfy.Save3DAdvanced',
|
||||
'output'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ export type MaterialMode =
|
||||
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
|
||||
export type CameraType = 'perspective' | 'orthographic'
|
||||
export type BackgroundRenderModeType = 'tiled' | 'panorama'
|
||||
export type LoadFolder = 'temp' | 'output'
|
||||
|
||||
interface CameraQuaternion {
|
||||
x: number
|
||||
|
||||
@@ -3,24 +3,21 @@
|
||||
* Adding a new node type that uses the viewer = one line change here.
|
||||
*/
|
||||
|
||||
const LOAD3D_RESULT_VIEWER_NODES = new Set([
|
||||
const LOAD3D_PREVIEW_NODES = new Set([
|
||||
'Preview3D',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
'PreviewPointCloud'
|
||||
])
|
||||
|
||||
const LOAD3D_ALL_NODES = new Set([
|
||||
...LOAD3D_RESULT_VIEWER_NODES,
|
||||
...LOAD3D_PREVIEW_NODES,
|
||||
'Load3D',
|
||||
'Load3DAdvanced',
|
||||
'SaveGLB'
|
||||
])
|
||||
|
||||
export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
|
||||
LOAD3D_RESULT_VIEWER_NODES.has(nodeType)
|
||||
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
|
||||
LOAD3D_PREVIEW_NODES.has(nodeType)
|
||||
|
||||
export const isLoad3dNode = (nodeType: string): boolean =>
|
||||
LOAD3D_ALL_NODES.has(nodeType)
|
||||
|
||||
@@ -90,10 +90,7 @@ describe('load3dLazy', () => {
|
||||
'Preview3D',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud',
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
'SaveGLB'
|
||||
])(
|
||||
'recognizes %s as a 3D node type and triggers the lazy-load path',
|
||||
async (nodeType) => {
|
||||
|
||||
@@ -76,24 +76,14 @@ type ExtCreated = ComfyExtension & {
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
splatExt: ExtCreated
|
||||
pointCloudExt: ExtCreated
|
||||
saveSplatExt: ExtCreated
|
||||
savePointCloudExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3dPreviewExtensions')
|
||||
const extByName = (name: string): ExtCreated => {
|
||||
const call = registerExtensionMock.mock.calls.find(
|
||||
(c) => (c[0] as ExtCreated).name === name
|
||||
)
|
||||
if (!call) throw new Error(`Extension ${name} was not registered`)
|
||||
return call[0] as ExtCreated
|
||||
}
|
||||
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
|
||||
return {
|
||||
splatExt: extByName('Comfy.PreviewGaussianSplat'),
|
||||
pointCloudExt: extByName('Comfy.PreviewPointCloud'),
|
||||
saveSplatExt: extByName('Comfy.SaveGaussianSplat'),
|
||||
savePointCloudExt: extByName('Comfy.SavePointCloud')
|
||||
splatExt: splatCall[0] as ExtCreated,
|
||||
pointCloudExt: pointCloudCall[0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +92,6 @@ interface FakeLoad3d {
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
forceRender: ReturnType<typeof vi.fn>
|
||||
setCameraState: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
setTargetSize: ReturnType<typeof vi.fn>
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
getCameraState: ReturnType<typeof vi.fn>
|
||||
@@ -117,7 +106,6 @@ function makeLoad3dMock(): FakeLoad3d {
|
||||
isSplatModel: vi.fn(() => false),
|
||||
forceRender: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
applyModelTransform: vi.fn(),
|
||||
setTargetSize: vi.fn(),
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
|
||||
@@ -163,59 +151,12 @@ function setupBaseMocks() {
|
||||
describe('load3dPreviewExtensions module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers preview and save extensions on import', async () => {
|
||||
const { splatExt, pointCloudExt, saveSplatExt, savePointCloudExt } =
|
||||
await loadExtensionsFresh()
|
||||
it('registers both preview extensions on import', async () => {
|
||||
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
|
||||
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
|
||||
expect(saveSplatExt.name).toBe('Comfy.SaveGaussianSplat')
|
||||
expect(savePointCloudExt.name).toBe('Comfy.SavePointCloud')
|
||||
})
|
||||
|
||||
it('save extensions load the saved file from the output folder, not temp', async () => {
|
||||
const { saveSplatExt, savePointCloudExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const splatNode = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
|
||||
await saveSplatExt.nodeCreated(splatNode)
|
||||
splatNode.onExecuted!({ result: ['3d/ComfyUI_00001_.ply'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
|
||||
const pcNode = makePreviewNode({ comfyClass: 'SavePointCloud' })
|
||||
await savePointCloudExt.nodeCreated(pcNode)
|
||||
pcNode.onExecuted!({ result: ['3d/ComfyUI_00002_.ply'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00002_.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('restores persisted models from the output folder on nodeCreated, not temp', async () => {
|
||||
const { saveSplatExt } = await loadExtensionsFresh()
|
||||
const node = makePreviewNode({
|
||||
comfyClass: 'SaveGaussianSplat',
|
||||
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.ply' }
|
||||
})
|
||||
|
||||
await saveSplatExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -273,44 +214,6 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
expect(cameraConfig?.state).toEqual(cameraState)
|
||||
})
|
||||
|
||||
it('applies onExecuted results to the remounted instance, not the disposed closure', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const original = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(original)
|
||||
)
|
||||
const node = makePreviewNode()
|
||||
|
||||
await splatExt.nodeCreated(node)
|
||||
|
||||
const remounted = makeLoad3dMock()
|
||||
nodeToLoad3dMapMock.set(node, remounted)
|
||||
|
||||
node.onExecuted!({
|
||||
result: ['scene.ply', { position: { x: 1, y: 2, z: 3 } }]
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(remounted.forceRender).toHaveBeenCalled()
|
||||
expect(original.forceRender).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-applies the model transform from result[2] on execute', async () => {
|
||||
const { saveSplatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
|
||||
const transform = { position: { x: 1, y: 2, z: 3 } }
|
||||
|
||||
await saveSplatExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['scene.ply', undefined, [transform]] })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
})
|
||||
|
||||
it('syncs width/height widgets to load3d.setTargetSize and registers callbacks', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
LoadFolder,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
@@ -30,9 +29,7 @@ function applyResultToLoad3d(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d,
|
||||
filePath: string,
|
||||
cameraState: CameraState | undefined,
|
||||
modelTransform: Model3DInfo[number] | undefined,
|
||||
loadFolder: LoadFolder
|
||||
cameraState: CameraState | undefined
|
||||
): void {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
@@ -49,7 +46,7 @@ function applyResultToLoad3d(
|
||||
}
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, normalizedPath, {
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -57,15 +54,13 @@ function applyResultToLoad3d(
|
||||
void load3d.whenLoadIdle().then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
load3d.forceRender()
|
||||
})
|
||||
}
|
||||
|
||||
function createPreview3DExtension(
|
||||
comfyClass: string,
|
||||
extensionName: string,
|
||||
loadFolder: LoadFolder
|
||||
extensionName: string
|
||||
): ComfyExtension {
|
||||
const applyPreviewOutput = (
|
||||
node: LGraphNode,
|
||||
@@ -73,18 +68,10 @@ function createPreview3DExtension(
|
||||
): void => {
|
||||
const filePath = result[0]
|
||||
const cameraState = result[1]
|
||||
const modelTransform = result[2]?.[0]
|
||||
if (!filePath) return
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
applyResultToLoad3d(
|
||||
node,
|
||||
load3d,
|
||||
filePath,
|
||||
cameraState,
|
||||
modelTransform,
|
||||
loadFolder
|
||||
)
|
||||
applyResultToLoad3d(node, load3d, filePath, cameraState)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,7 +119,7 @@ function createPreview3DExtension(
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -149,8 +136,6 @@ function createPreview3DExtension(
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
@@ -163,10 +148,10 @@ function createPreview3DExtension(
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
load3d.setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
load3d.setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,14 +199,7 @@ function createPreview3DExtension(
|
||||
return
|
||||
}
|
||||
|
||||
applyResultToLoad3d(
|
||||
node,
|
||||
resolveLoad3d(),
|
||||
filePath,
|
||||
result?.[1],
|
||||
result?.[2]?.[0],
|
||||
loadFolder
|
||||
)
|
||||
applyResultToLoad3d(node, load3d, filePath, result?.[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -229,26 +207,8 @@ function createPreview3DExtension(
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension(
|
||||
'PreviewGaussianSplat',
|
||||
'Comfy.PreviewGaussianSplat',
|
||||
'temp'
|
||||
)
|
||||
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension(
|
||||
'PreviewPointCloud',
|
||||
'Comfy.PreviewPointCloud',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension(
|
||||
'SaveGaussianSplat',
|
||||
'Comfy.SaveGaussianSplat',
|
||||
'output'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension('SavePointCloud', 'Comfy.SavePointCloud', 'output')
|
||||
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
|
||||
)
|
||||
|
||||
@@ -81,9 +81,6 @@ describe('Comfy.SaveImageExtraOutput', () => {
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
|
||||
@@ -16,9 +16,6 @@ const saveNodeTypes = new Set([
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { serializesWorkflowWidget } from './reviewGuidanceVerification'
|
||||
|
||||
function createGraph() {
|
||||
const graph = new LGraph()
|
||||
graph.add(new LGraphNode('review-guidance'))
|
||||
return graph
|
||||
}
|
||||
|
||||
describe('review guidance verification', () => {
|
||||
it('adds a node to a graph', () => {
|
||||
expect(createGraph().nodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('creates isolated graphs', () => {
|
||||
expect(createGraph()).not.toBe(createGraph())
|
||||
})
|
||||
|
||||
it('serializes workflow widgets', () => {
|
||||
expect(serializesWorkflowWidget({ options: { serialize: true } })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
interface ReviewGuidanceWidget {
|
||||
options: {
|
||||
serialize?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function serializesWorkflowWidget(
|
||||
widget: ReviewGuidanceWidget
|
||||
): boolean {
|
||||
return widget.options.serialize === true
|
||||
}
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "يرجى إبقاء إجابتك أقل من {max} حرفًا.",
|
||||
"chooseAnOption": "يرجى اختيار خيار.",
|
||||
"describeAnswer": "يرجى وصف إجابتك.",
|
||||
"selectAtLeastOne": "يرجى اختيار خيار واحد على الأقل."
|
||||
},
|
||||
"intro": "ساعدنا في تخصيص تجربتك مع ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "جديد على ComfyUI",
|
||||
"pro": "أنا مستخدم محترف",
|
||||
"some": "لدي معرفة جيدة"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "عُقد مخصصة",
|
||||
"pipelines": "مسارات مؤتمتة",
|
||||
"products": "منتجات للآخرين"
|
||||
"familiarity": {
|
||||
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
|
||||
"basics": "مرتاح مع الأساسيات",
|
||||
"expert": "خبير (أساعد الآخرين)",
|
||||
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
|
||||
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "تطبيقات وواجهات برمجة التطبيقات",
|
||||
"exploring": "أستكشف فقط",
|
||||
"3d_game": "أصول ثلاثية الأبعاد / أصول ألعاب",
|
||||
"api": "نقاط نهاية API لتشغيل مسارات العمل",
|
||||
"apps": "تطبيقات مبسطة من مسارات العمل",
|
||||
"audio": "صوت / موسيقى",
|
||||
"custom_nodes": "عُقد مخصصة",
|
||||
"images": "صور",
|
||||
"other": "شيء آخر",
|
||||
"otherPlaceholder": "ماذا تريد أن تصنع؟",
|
||||
"video": "فيديو",
|
||||
"not_sure": "لست متأكداً",
|
||||
"videos": "فيديوهات",
|
||||
"workflows": "مسارات عمل أو خطوط معالجة مخصصة"
|
||||
},
|
||||
"source": {
|
||||
"community": "مجتمع أو منتدى",
|
||||
"conference": "مؤتمر أو فعالية",
|
||||
"discord": "ديسكورد / مجتمع",
|
||||
"friend": "صديق أو زميل",
|
||||
"other": "أخرى",
|
||||
"otherPlaceholder": "من أين وجدتنا؟",
|
||||
"search": "جوجل / بحث",
|
||||
"social": "وسائل التواصل الاجتماعي"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "ديسكورد",
|
||||
"github": "GitHub",
|
||||
"instagram": "إنستغرام",
|
||||
"linkedin": "لينكدإن",
|
||||
"newsletter": "النشرة البريدية أو مدونة",
|
||||
"other": "أخرى",
|
||||
"reddit": "ريديت",
|
||||
"tiktok": "تيك توك",
|
||||
"twitter": "X (تويتر)",
|
||||
"search": "جوجل / بحث",
|
||||
"twitter": "تويتر / X",
|
||||
"youtube": "يوتيوب"
|
||||
},
|
||||
"usage": {
|
||||
"education": "تعليمي (طالب أو معلم)",
|
||||
"personal": "استخدام شخصي",
|
||||
"work": "عمل"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "أخبرنا المزيد",
|
||||
"placeholder": "نص بديل لأسئلة الاستبيان",
|
||||
"steps": {
|
||||
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
|
||||
"source": "من أين سمعت عن ComfyUI؟",
|
||||
"usage": "كيف تخطط لاستخدام ComfyUI؟"
|
||||
},
|
||||
"title": "استبيان السحابة"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "تعرف على السحابة",
|
||||
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
|
||||
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
|
||||
"cloudSurvey_steps_experience": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"cloudSurvey_steps_focus": "ماذا تبني؟",
|
||||
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"cloudSurvey_steps_intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
|
||||
"cloudSurvey_steps_source": "من أين سمعت عن ComfyUI؟",
|
||||
"cloudSurvey_steps_source_social": "أي منصة؟",
|
||||
"cloudSurvey_steps_usage": "كيف تخطط لاستخدام ComfyUI؟",
|
||||
"cloudWaitlist_contactLink": "هنا",
|
||||
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
|
||||
"color": {
|
||||
|
||||
@@ -2170,8 +2170,7 @@
|
||||
"descLabel": "description",
|
||||
"textPlaceholder": "text to render (verbatim)",
|
||||
"descPlaceholder": "description of this region",
|
||||
"colors": "color_palette",
|
||||
"grid": "Grid"
|
||||
"colors": "color_palette"
|
||||
},
|
||||
"palette": {
|
||||
"addColor": "Add a color",
|
||||
@@ -2415,16 +2414,6 @@
|
||||
"tooltipLearnMore": "Learn more..."
|
||||
}
|
||||
},
|
||||
"desktopLogin": {
|
||||
"confirmSummary": "Approve desktop sign-in?",
|
||||
"confirmMessage": "The ComfyUI desktop app is waiting to sign in with your account. Only continue if you just started signing in from the ComfyUI desktop app.",
|
||||
"successSummary": "Signed in",
|
||||
"successDetail": "You can return to the ComfyUI desktop app.",
|
||||
"expiredSummary": "Desktop sign-in failed",
|
||||
"expiredDetail": "The sign-in request expired or was already used. Start signing in again from the ComfyUI desktop app.",
|
||||
"failedSummary": "Desktop sign-in failed",
|
||||
"failedDetail": "Something went wrong completing the desktop sign-in. Start signing in again from the ComfyUI desktop app."
|
||||
},
|
||||
"validation": {
|
||||
"invalidEmail": "Invalid email address",
|
||||
"required": "Required",
|
||||
@@ -2604,7 +2593,7 @@
|
||||
"additionalCreditsInfo": "About additional credits",
|
||||
"additionalCredits": "Additional credits",
|
||||
"additionalCreditsInUse": "In use",
|
||||
"usedAfterMonthly": "Used after monthly runs out",
|
||||
"usedAfterMonthly": "Used after plan credits run out",
|
||||
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
|
||||
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
|
||||
@@ -2842,7 +2831,10 @@
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you.",
|
||||
"sendInvites": "Send invites"
|
||||
}
|
||||
},
|
||||
"enterprisePlanName": "Enterprise",
|
||||
"percentUsed": "{percent}% used",
|
||||
"usageProgress": "{used} of {total} credits used"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "My Account Settings",
|
||||
@@ -2857,7 +2849,7 @@
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"inviteLimitReached": "Your workspace is at the member limit",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2872,12 +2864,17 @@
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
"pendingCount": "Pending ({count})"
|
||||
"pendingCount": "Pending ({count})",
|
||||
"membersCount": "Members ({count})",
|
||||
"pending": "Pending"
|
||||
},
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"creditsUsed": "Credits used this month",
|
||||
"email": "Email",
|
||||
"lastActivity": "Last activity"
|
||||
},
|
||||
"actions": {
|
||||
"resendInvite": "Resend invite",
|
||||
@@ -2893,14 +2890,22 @@
|
||||
"contactUs": "Contact us",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"searchPlaceholder": "Search..."
|
||||
"searchPlaceholder": "Search...",
|
||||
"activity": {
|
||||
"daysAgo": "{count} day ago | {count} days ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago"
|
||||
},
|
||||
"membersUsage": "{count} of {max} total members."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created",
|
||||
"renameWorkspace": "Rename Workspace"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
@@ -2989,6 +2994,82 @@
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
"failedToLeaveWorkspace": "Failed to leave workspace",
|
||||
"failedToFetchWorkspaces": "Failed to load workspaces"
|
||||
},
|
||||
"charactersLeft": "{count} character left | {count} characters left",
|
||||
"doubleClickToRename": "Double-click to rename",
|
||||
"editWorkspaceImage": "Edit workspace image",
|
||||
"memberLimitDialog": {
|
||||
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats.",
|
||||
"title": "Workspace is at the member limit"
|
||||
},
|
||||
"requestMore": "Request more",
|
||||
"workflowQueuedDialog": {
|
||||
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity.",
|
||||
"title": "Your workflow is queued"
|
||||
},
|
||||
"billingStatus": {
|
||||
"ending": {
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan",
|
||||
"title": "Your team plan ends on {date}"
|
||||
},
|
||||
"outOfCredits": {
|
||||
"addCredits": "Add credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"dismiss": "Dismiss",
|
||||
"title": "Out of credits"
|
||||
},
|
||||
"paused": {
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method.",
|
||||
"title": "Subscription paused"
|
||||
},
|
||||
"updatePayment": "Update payment",
|
||||
"warning": {
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"title": "Payment declined"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"changePlan": "Change plan",
|
||||
"inactive": {
|
||||
"reactivate": "Reactivate plan",
|
||||
"subtitle": "Reactivate your team plan to add more members and run workflows",
|
||||
"subtitleEnterprise": "Reactivate your enterprise plan to add more members and run workflows",
|
||||
"title": "Inactive team subscription",
|
||||
"titleEnterprise": "Inactive enterprise subscription"
|
||||
},
|
||||
"learnMore": "Learn more",
|
||||
"managePayment": "Manage payment",
|
||||
"messageSupport": "Message support",
|
||||
"paused": "Paused",
|
||||
"perMonth": "mo",
|
||||
"pricingTable": "Partner Nodes pricing table",
|
||||
"renewsOn": "Renews on {date}",
|
||||
"seeMore": "See more",
|
||||
"snapshot": {
|
||||
"creditsUsed": "Credits used",
|
||||
"empty": {
|
||||
"recentActivity": "No activity yet",
|
||||
"topSpenders": "No credits used yet this month"
|
||||
},
|
||||
"lastActivity": "Last activity",
|
||||
"recentActivity": "Recent activity",
|
||||
"topSpenders": "Top spenders",
|
||||
"user": "User"
|
||||
},
|
||||
"nextInvoice": "Next month invoice",
|
||||
"usd": "USD"
|
||||
},
|
||||
"invoices": {
|
||||
"fullHistory": "Full invoice history"
|
||||
},
|
||||
"planCredits": {
|
||||
"tabs": {
|
||||
"invoices": "Invoices",
|
||||
"overview": "Credits"
|
||||
}
|
||||
}
|
||||
},
|
||||
"teamWorkspacesDialog": {
|
||||
@@ -3001,7 +3082,7 @@
|
||||
"newWorkspace": "New workspace",
|
||||
"namePlaceholder": "e.g. Marketing Team",
|
||||
"createWorkspace": "Create workspace",
|
||||
"nameValidationError": "Name must be 1–50 characters using letters, numbers, spaces, or common punctuation."
|
||||
"nameValidationError": "Name must be 1–30 characters using letters, numbers, spaces, or common punctuation."
|
||||
},
|
||||
"workspaceSwitcher": {
|
||||
"switchWorkspace": "Switch workspace",
|
||||
@@ -3011,7 +3092,8 @@
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create a workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
|
||||
"failedToSwitch": "Failed to switch workspace"
|
||||
"failedToSwitch": "Failed to switch workspace",
|
||||
"roleAdmin": "Admin"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Por favor, mantén tu respuesta por debajo de {max} caracteres.",
|
||||
"chooseAnOption": "Por favor, elige una opción.",
|
||||
"describeAnswer": "Por favor, describe tu respuesta.",
|
||||
"selectAtLeastOne": "Por favor, selecciona al menos una opción."
|
||||
},
|
||||
"intro": "Ayúdanos a personalizar tu experiencia con ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "Nuevo en ComfyUI",
|
||||
"pro": "Soy usuario avanzado",
|
||||
"some": "Ya tengo experiencia"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nodos personalizados",
|
||||
"pipelines": "Pipelines automatizados",
|
||||
"products": "Productos para otros"
|
||||
"familiarity": {
|
||||
"advanced": "Usuario avanzado (flujos de trabajo personalizados)",
|
||||
"basics": "Cómodo con lo básico",
|
||||
"expert": "Experto (ayudo a otros)",
|
||||
"new": "Nuevo en ComfyUI (nunca lo he usado antes)",
|
||||
"starting": "Recién comenzando (siguiendo tutoriales)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Aplicaciones y APIs",
|
||||
"exploring": "Solo explorando",
|
||||
"3d_game": "Recursos 3D / recursos para juegos",
|
||||
"api": "Endpoints de API para ejecutar flujos de trabajo",
|
||||
"apps": "Apps simplificadas a partir de flujos de trabajo",
|
||||
"audio": "Audio / música",
|
||||
"custom_nodes": "Nodos personalizados",
|
||||
"images": "Imágenes",
|
||||
"other": "Otra cosa",
|
||||
"otherPlaceholder": "¿Qué quieres crear?",
|
||||
"video": "Video",
|
||||
"not_sure": "No estoy seguro",
|
||||
"videos": "Videos",
|
||||
"workflows": "Flujos de trabajo o pipelines personalizados"
|
||||
},
|
||||
"source": {
|
||||
"community": "Una comunidad o foro",
|
||||
"conference": "Conferencia o evento",
|
||||
"discord": "Discord / comunidad",
|
||||
"friend": "Amigo o colega",
|
||||
"other": "Otro",
|
||||
"otherPlaceholder": "¿Dónde nos encontraste?",
|
||||
"search": "Google / búsqueda",
|
||||
"social": "Redes sociales"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Newsletter o blog",
|
||||
"other": "Otro",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / búsqueda",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Educación (estudiante o docente)",
|
||||
"personal": "Uso personal",
|
||||
"work": "Trabajo"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Cuéntanos más",
|
||||
"placeholder": "Marcador de posición para preguntas de la encuesta",
|
||||
"steps": {
|
||||
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
||||
"intent": "¿Qué quieres crear con ComfyUI?",
|
||||
"source": "¿Dónde escuchaste sobre ComfyUI?",
|
||||
"usage": "¿Cómo planeas usar ComfyUI?"
|
||||
},
|
||||
"title": "Encuesta en la Nube"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "Conoce más sobre Cloud",
|
||||
"cloudStart_title": "comienza a crear en segundos",
|
||||
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
|
||||
"cloudSurvey_steps_experience": "¿Qué tanto conoces ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "¿Qué estás construyendo?",
|
||||
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "¿Qué quieres crear con ComfyUI?",
|
||||
"cloudSurvey_steps_source": "¿Dónde escuchaste sobre ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "¿En qué plataforma?",
|
||||
"cloudSurvey_steps_usage": "¿Cómo planeas usar ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "aquí",
|
||||
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "لطفاً پاسخ خود را کمتر از {max} نویسه نگه دارید.",
|
||||
"chooseAnOption": "لطفاً یک گزینه را انتخاب کنید.",
|
||||
"describeAnswer": "لطفاً پاسخ خود را توضیح دهید.",
|
||||
"selectAtLeastOne": "لطفاً حداقل یک گزینه را انتخاب کنید."
|
||||
},
|
||||
"intro": "به ما کمک کنید تا تجربه شما از ComfyUI را متناسبسازی کنیم.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "جدید در ComfyUI",
|
||||
"pro": "کاربر حرفهای هستم",
|
||||
"some": "آشنایی نسبی دارم"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nodeهای سفارشی",
|
||||
"pipelines": "پایپلاینهای خودکار",
|
||||
"products": "محصولات برای دیگران"
|
||||
"familiarity": {
|
||||
"advanced": "کاربر پیشرفته (جریانکارهای سفارشی)",
|
||||
"basics": "آشنایی با مبانی",
|
||||
"expert": "کاربر خبره (به دیگران کمک میکنم)",
|
||||
"new": "جدید در ComfyUI (تا کنون استفاده نکردهام)",
|
||||
"starting": "تازه شروع کردهام (در حال دنبال کردن آموزشها)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "اپلیکیشنها و APIها",
|
||||
"exploring": "فقط در حال بررسی",
|
||||
"3d_game": "دارایی سهبعدی / دارایی بازی",
|
||||
"api": "API endpoint برای اجرای workflow",
|
||||
"apps": "اپلیکیشن سادهشده از workflow",
|
||||
"audio": "صدا / موسیقی",
|
||||
"custom_nodes": "node سفارشی",
|
||||
"images": "تصویر",
|
||||
"other": "چیز دیگری",
|
||||
"otherPlaceholder": "چه چیزی میخواهید بسازید؟",
|
||||
"video": "ویدیو",
|
||||
"not_sure": "مطمئن نیستم",
|
||||
"videos": "ویدیو",
|
||||
"workflows": "workflow یا pipeline سفارشی"
|
||||
},
|
||||
"source": {
|
||||
"community": "انجمن یا فروم",
|
||||
"conference": "کنفرانس یا رویداد",
|
||||
"discord": "Discord / انجمن",
|
||||
"friend": "دوست یا همکار",
|
||||
"other": "سایر",
|
||||
"otherPlaceholder": "از کجا با ما آشنا شدید؟",
|
||||
"search": "Google / جستجو",
|
||||
"social": "رسانههای اجتماعی"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "خبرنامه یا وبلاگ",
|
||||
"other": "سایر",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / جستجو",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "آموزشی (دانشجو یا مدرس)",
|
||||
"personal": "استفاده شخصی",
|
||||
"work": "کاری"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "بیشتر توضیح دهید",
|
||||
"placeholder": "جاینگهدار سوالات نظرسنجی",
|
||||
"steps": {
|
||||
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
|
||||
"intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
|
||||
"source": "از کجا با ComfyUI آشنا شدید؟",
|
||||
"usage": "برنامه شما برای استفاده از ComfyUI چیست؟"
|
||||
},
|
||||
"title": "نظرسنجی ابری"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "درباره Cloud بیشتر بدانید",
|
||||
"cloudStart_title": "در چند ثانیه شروع به خلق کنید",
|
||||
"cloudStart_wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟",
|
||||
"cloudSurvey_steps_experience": "تا چه حد با ComfyUI آشنایی دارید؟",
|
||||
"cloudSurvey_steps_focus": "در حال ساخت چه چیزی هستید؟",
|
||||
"cloudSurvey_steps_familiarity": "تا چه اندازه با ComfyUI آشنایی دارید؟",
|
||||
"cloudSurvey_steps_intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
|
||||
"cloudSurvey_steps_source": "از کجا با ComfyUI آشنا شدید؟",
|
||||
"cloudSurvey_steps_source_social": "کدام پلتفرم؟",
|
||||
"cloudSurvey_steps_usage": "برنامه شما برای استفاده از ComfyUI چیست؟",
|
||||
"cloudWaitlist_contactLink": "اینجا",
|
||||
"cloudWaitlist_questionsText": "سؤالی دارید؟ با ما تماس بگیرید",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Veuillez limiter votre réponse à {max} caractères.",
|
||||
"chooseAnOption": "Veuillez choisir une option.",
|
||||
"describeAnswer": "Veuillez décrire votre réponse.",
|
||||
"selectAtLeastOne": "Veuillez sélectionner au moins une option."
|
||||
},
|
||||
"intro": "Aidez-nous à personnaliser votre expérience ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "Nouveau sur ComfyUI",
|
||||
"pro": "Utilisateur avancé",
|
||||
"some": "Je me débrouille"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nœuds personnalisés",
|
||||
"pipelines": "Pipelines automatisés",
|
||||
"products": "Produits pour les autres"
|
||||
"familiarity": {
|
||||
"advanced": "Utilisateur avancé (workflows personnalisés)",
|
||||
"basics": "À l'aise avec les bases",
|
||||
"expert": "Expert (j'aide les autres)",
|
||||
"new": "Nouveau sur ComfyUI (jamais utilisé auparavant)",
|
||||
"starting": "Je débute (je suis des tutoriels)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Applications et API",
|
||||
"exploring": "Je découvre simplement",
|
||||
"3d_game": "Assets 3D / assets de jeu",
|
||||
"api": "Points de terminaison API pour exécuter des workflows",
|
||||
"apps": "Applications simplifiées à partir de workflows",
|
||||
"audio": "Audio / musique",
|
||||
"custom_nodes": "Nœuds personnalisés",
|
||||
"images": "Images",
|
||||
"other": "Autre chose",
|
||||
"otherPlaceholder": "Qu'aimeriez-vous créer ?",
|
||||
"video": "Vidéo",
|
||||
"not_sure": "Pas sûr",
|
||||
"videos": "Vidéos",
|
||||
"workflows": "Workflows ou pipelines personnalisés"
|
||||
},
|
||||
"source": {
|
||||
"community": "Une communauté ou un forum",
|
||||
"conference": "Conférence ou événement",
|
||||
"discord": "Discord / communauté",
|
||||
"friend": "Ami ou collègue",
|
||||
"other": "Autre",
|
||||
"otherPlaceholder": "Où nous avez-vous trouvés ?",
|
||||
"search": "Google / recherche",
|
||||
"social": "Réseaux sociaux"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Newsletter ou blog",
|
||||
"other": "Autre",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / recherche",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Éducation (étudiant ou enseignant)",
|
||||
"personal": "Usage personnel",
|
||||
"work": "Travail"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Dites-nous en plus",
|
||||
"placeholder": "Texte indicatif des questions de l'enquête",
|
||||
"steps": {
|
||||
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
||||
"intent": "Que souhaitez-vous créer avec ComfyUI ?",
|
||||
"source": "Où avez-vous entendu parler de ComfyUI ?",
|
||||
"usage": "Comment prévoyez-vous d'utiliser ComfyUI ?"
|
||||
},
|
||||
"title": "Enquête Cloud"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "En savoir plus sur Cloud",
|
||||
"cloudStart_title": "créez en quelques secondes",
|
||||
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
|
||||
"cloudSurvey_steps_experience": "Quel est votre niveau de connaissance de ComfyUI ?",
|
||||
"cloudSurvey_steps_focus": "Qu'êtes-vous en train de créer ?",
|
||||
"cloudSurvey_steps_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
||||
"cloudSurvey_steps_intent": "Que souhaitez-vous créer avec ComfyUI ?",
|
||||
"cloudSurvey_steps_source": "Où avez-vous entendu parler de ComfyUI ?",
|
||||
"cloudSurvey_steps_source_social": "Quelle plateforme ?",
|
||||
"cloudSurvey_steps_usage": "Comment prévoyez-vous d'utiliser ComfyUI ?",
|
||||
"cloudWaitlist_contactLink": "ici",
|
||||
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "אנא שמרו את התשובה שלכם עד {max} תווים.",
|
||||
"chooseAnOption": "אנא בחר אפשרות.",
|
||||
"describeAnswer": "אנא תאר את תשובתך.",
|
||||
"selectAtLeastOne": "אנא בחר לפחות אפשרות אחת."
|
||||
},
|
||||
"intro": "עזרו לנו להתאים את חוויית ה-ComfyUI שלך.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "חדש/ה ב-ComfyUI",
|
||||
"pro": "משתמש/ת מתקדם/ת",
|
||||
"some": "מכיר/ה את המערכת"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "צמתים מותאמים אישית",
|
||||
"pipelines": "צינורות עבודה אוטומטיים",
|
||||
"products": "מוצרים לאחרים"
|
||||
"familiarity": {
|
||||
"advanced": "מתקדם — בונה ועורך תהליכי עבודה",
|
||||
"basics": "בינוני — מרגיש בנוח עם היסודות",
|
||||
"expert": "מומחה — אני עוזר לאחרים",
|
||||
"new": "חדש — מעולם לא השתמשתי",
|
||||
"starting": "מתחיל — עוקב אחר מדריכים"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "אפליקציות ו-API",
|
||||
"exploring": "רק בודק/ת",
|
||||
"3d_game": "נכסי תלת-ממד / נכסי משחקים",
|
||||
"api": "נקודות קצה של API להרצת תהליכי עבודה",
|
||||
"apps": "יישומים מפושטים מתהליכי עבודה",
|
||||
"audio": "שמע / מוזיקה",
|
||||
"custom_nodes": "צמתים מותאמים",
|
||||
"images": "תמונות",
|
||||
"other": "משהו אחר",
|
||||
"otherPlaceholder": "מה תרצו ליצור?",
|
||||
"video": "וידאו",
|
||||
"not_sure": "לא בטוח",
|
||||
"videos": "סרטונים",
|
||||
"workflows": "תהליכי עבודה או צינורות (pipelines) מותאמים"
|
||||
},
|
||||
"source": {
|
||||
"community": "קהילה או פורום",
|
||||
"conference": "כנס או אירוע",
|
||||
"discord": "Discord / קהילה",
|
||||
"friend": "חבר או עמית",
|
||||
"other": "אחר",
|
||||
"otherPlaceholder": "היכן שמעתם עלינו?",
|
||||
"search": "Google / חיפוש",
|
||||
"social": "רשתות חברתיות"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "ניוזלטר או בלוג",
|
||||
"other": "אחר",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / חיפוש",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "חינוך (סטודנט או מרצה)",
|
||||
"personal": "שימוש אישי",
|
||||
"work": "עבודה"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "ספרו לנו עוד",
|
||||
"placeholder": "מציין מיקום לשאלות הסקר",
|
||||
"steps": {
|
||||
"familiarity": "עד כמה אתה מכיר את ComfyUI?",
|
||||
"intent": "מה ברצונך ליצור עם ComfyUI?",
|
||||
"source": "היכן שמעת על ComfyUI?",
|
||||
"usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?"
|
||||
},
|
||||
"title": "סקר ענן"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "למד על הענן",
|
||||
"cloudStart_title": "התחל ליצור תוך שניות",
|
||||
"cloudStart_wantToRun": "מעדיף להריץ את ComfyUI מקומית?",
|
||||
"cloudSurvey_steps_experience": "עד כמה אתם מכירים את ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "מה אתם בונים?",
|
||||
"cloudSurvey_steps_familiarity": "עד כמה אתה מכיר את ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "מה ברצונך ליצור עם ComfyUI?",
|
||||
"cloudSurvey_steps_source": "היכן שמעת על ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "באיזו פלטפורמה?",
|
||||
"cloudSurvey_steps_usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "כאן",
|
||||
"cloudWaitlist_questionsText": "שאלות? צור איתנו קשר",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "回答は{max}文字以内で入力してください。",
|
||||
"chooseAnOption": "オプションを選択してください。",
|
||||
"describeAnswer": "回答を記述してください。",
|
||||
"selectAtLeastOne": "少なくとも1つ選択してください。"
|
||||
},
|
||||
"intro": "ComfyUIの体験をより最適化するためにご協力ください。",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUIは初めて",
|
||||
"pro": "上級ユーザー",
|
||||
"some": "ある程度使い方が分かる"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "カスタムノード",
|
||||
"pipelines": "自動パイプライン",
|
||||
"products": "他者向けプロダクト"
|
||||
"familiarity": {
|
||||
"advanced": "上級ユーザー(カスタムワークフロー)",
|
||||
"basics": "基本操作に慣れている",
|
||||
"expert": "エキスパート(他者を支援)",
|
||||
"new": "ComfyUI初心者(使用経験なし)",
|
||||
"starting": "使い始め(チュートリアルをフォロー中)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "アプリ・API",
|
||||
"exploring": "探索中",
|
||||
"3d_game": "3Dアセット/ゲームアセット",
|
||||
"api": "ワークフロー実行用APIエンドポイント",
|
||||
"apps": "ワークフローから簡易アプリ作成",
|
||||
"audio": "音声/音楽",
|
||||
"custom_nodes": "カスタムノード",
|
||||
"images": "画像",
|
||||
"other": "その他",
|
||||
"otherPlaceholder": "何を作りたいですか?",
|
||||
"video": "動画",
|
||||
"not_sure": "まだ分からない",
|
||||
"videos": "動画",
|
||||
"workflows": "カスタムワークフローやパイプライン"
|
||||
},
|
||||
"source": {
|
||||
"community": "コミュニティ・フォーラム",
|
||||
"conference": "カンファレンスやイベント",
|
||||
"discord": "Discord/コミュニティ",
|
||||
"friend": "友人または同僚",
|
||||
"other": "その他",
|
||||
"otherPlaceholder": "どこで私たちを知りましたか?",
|
||||
"search": "Google/検索",
|
||||
"social": "ソーシャルメディア"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "ニュースレターやブログ",
|
||||
"other": "その他",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X(Twitter)",
|
||||
"search": "Google/検索",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "教育(学生または教育者)",
|
||||
"personal": "個人利用",
|
||||
"work": "仕事"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "詳細をお聞かせください",
|
||||
"placeholder": "アンケート質問のプレースホルダー",
|
||||
"steps": {
|
||||
"familiarity": "ComfyUIの使用経験はどの程度ですか?",
|
||||
"intent": "ComfyUIで何を作成したいですか?",
|
||||
"source": "ComfyUIをどこで知りましたか?",
|
||||
"usage": "ComfyUIをどのように利用する予定ですか?"
|
||||
},
|
||||
"title": "クラウドアンケート"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "クラウドについて学ぶ",
|
||||
"cloudStart_title": "数秒で作成を開始",
|
||||
"cloudStart_wantToRun": "代わりにローカルでComfyUIを実行したいですか?",
|
||||
"cloudSurvey_steps_experience": "ComfyUIの知識レベルは?",
|
||||
"cloudSurvey_steps_focus": "何を作成していますか?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUIにどの程度精通していますか?",
|
||||
"cloudSurvey_steps_intent": "ComfyUIで何を作成したいですか?",
|
||||
"cloudSurvey_steps_source": "ComfyUIをどこで知りましたか?",
|
||||
"cloudSurvey_steps_source_social": "どのプラットフォームですか?",
|
||||
"cloudSurvey_steps_usage": "ComfyUIをどのように利用する予定ですか?",
|
||||
"cloudWaitlist_contactLink": "こちら",
|
||||
"cloudWaitlist_questionsText": "質問がありますか?お問い合わせください",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "답변은 {max}자 이내로 작성해 주세요.",
|
||||
"chooseAnOption": "옵션을 선택해 주세요.",
|
||||
"describeAnswer": "답변을 설명해 주세요.",
|
||||
"selectAtLeastOne": "최소 한 가지 옵션을 선택해 주세요."
|
||||
},
|
||||
"intro": "ComfyUI 경험을 맞춤화할 수 있도록 도와주세요.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUI가 처음이에요",
|
||||
"pro": "전문 사용자입니다",
|
||||
"some": "기본적인 사용법을 알아요"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "커스텀 노드",
|
||||
"pipelines": "자동화 파이프라인",
|
||||
"products": "타인을 위한 제품"
|
||||
"familiarity": {
|
||||
"advanced": "고급 사용자 (커스텀 워크플로우 사용)",
|
||||
"basics": "기본 기능에 익숙함",
|
||||
"expert": "전문가 (다른 사용자 도움)",
|
||||
"new": "ComfyUI 처음 사용 (이전에 사용한 적 없음)",
|
||||
"starting": "막 시작한 단계 (튜토리얼 따라하는 중)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "앱 및 API",
|
||||
"exploring": "그냥 둘러보는 중",
|
||||
"3d_game": "3D 에셋 / 게임 에셋",
|
||||
"api": "워크플로우 실행용 API 엔드포인트",
|
||||
"apps": "워크플로우 기반 간소화 앱",
|
||||
"audio": "오디오 / 음악",
|
||||
"custom_nodes": "커스텀 노드",
|
||||
"images": "이미지",
|
||||
"other": "기타",
|
||||
"otherPlaceholder": "무엇을 만들고 싶으신가요?",
|
||||
"video": "비디오",
|
||||
"not_sure": "잘 모르겠음",
|
||||
"videos": "비디오",
|
||||
"workflows": "맞춤형 워크플로우 또는 파이프라인"
|
||||
},
|
||||
"source": {
|
||||
"community": "커뮤니티 또는 포럼",
|
||||
"conference": "컨퍼런스 또는 이벤트",
|
||||
"discord": "Discord / 커뮤니티",
|
||||
"friend": "친구 또는 동료",
|
||||
"other": "기타",
|
||||
"otherPlaceholder": "어디서 저희를 알게 되셨나요?",
|
||||
"search": "Google / 검색",
|
||||
"social": "소셜 미디어"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "뉴스레터 또는 블로그",
|
||||
"other": "기타",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / 검색",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "교육용(학생 또는 교육자)",
|
||||
"personal": "개인용",
|
||||
"work": "업무용"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "자세히 알려주세요",
|
||||
"placeholder": "설문 질문 자리표시자",
|
||||
"steps": {
|
||||
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
||||
"intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
|
||||
"source": "ComfyUI를 어디에서 알게 되셨나요?",
|
||||
"usage": "ComfyUI를 어떻게 사용하실 계획인가요?"
|
||||
},
|
||||
"title": "클라우드 설문"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "클라우드 알아보기",
|
||||
"cloudStart_title": "몇 초 만에 제작 시작",
|
||||
"cloudStart_wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?",
|
||||
"cloudSurvey_steps_experience": "ComfyUI를 얼마나 잘 알고 계신가요?",
|
||||
"cloudSurvey_steps_focus": "무엇을 만들고 계신가요?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
||||
"cloudSurvey_steps_intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
|
||||
"cloudSurvey_steps_source": "ComfyUI를 어디에서 알게 되셨나요?",
|
||||
"cloudSurvey_steps_source_social": "어떤 플랫폼에서 알게 되셨나요?",
|
||||
"cloudSurvey_steps_usage": "ComfyUI를 어떻게 사용하실 계획인가요?",
|
||||
"cloudWaitlist_contactLink": "여기",
|
||||
"cloudWaitlist_questionsText": "질문이 있으신가요? 문의하기",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Por favor, mantenha sua resposta com menos de {max} caracteres.",
|
||||
"chooseAnOption": "Por favor, escolha uma opção.",
|
||||
"describeAnswer": "Por favor, descreva sua resposta.",
|
||||
"selectAtLeastOne": "Por favor, selecione pelo menos uma opção."
|
||||
},
|
||||
"intro": "Ajude-nos a personalizar sua experiência no ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "Novo no ComfyUI",
|
||||
"pro": "Sou um usuário avançado",
|
||||
"some": "Já conheço um pouco"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nós personalizados",
|
||||
"pipelines": "Pipelines automatizados",
|
||||
"products": "Produtos para outros"
|
||||
"familiarity": {
|
||||
"advanced": "Usuário avançado (fluxos de trabalho personalizados)",
|
||||
"basics": "Confortável com o básico",
|
||||
"expert": "Especialista (ajuda outras pessoas)",
|
||||
"new": "Novo no ComfyUI (nunca usei antes)",
|
||||
"starting": "Começando agora (seguindo tutoriais)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Apps e APIs",
|
||||
"exploring": "Só explorando",
|
||||
"3d_game": "Assets 3D / assets para jogos",
|
||||
"api": "Endpoints de API para executar workflows",
|
||||
"apps": "Apps simplificados a partir de workflows",
|
||||
"audio": "Áudio / música",
|
||||
"custom_nodes": "Nodes personalizados",
|
||||
"images": "Imagens",
|
||||
"other": "Outra coisa",
|
||||
"otherPlaceholder": "O que você quer criar?",
|
||||
"video": "Vídeo",
|
||||
"not_sure": "Não tenho certeza",
|
||||
"videos": "Vídeos",
|
||||
"workflows": "Workflows ou pipelines personalizados"
|
||||
},
|
||||
"source": {
|
||||
"community": "Uma comunidade ou fórum",
|
||||
"conference": "Conferência ou evento",
|
||||
"discord": "Discord / comunidade",
|
||||
"friend": "Amigo ou colega",
|
||||
"other": "Outro",
|
||||
"otherPlaceholder": "Onde você nos encontrou?",
|
||||
"search": "Google / busca",
|
||||
"social": "Mídias sociais"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Newsletter ou blog",
|
||||
"other": "Outro",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / busca",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Educação (estudante ou educador)",
|
||||
"personal": "Uso pessoal",
|
||||
"work": "Trabalho"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Conte-nos mais",
|
||||
"placeholder": "Espaço reservado para perguntas da pesquisa",
|
||||
"steps": {
|
||||
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
||||
"intent": "O que você deseja criar com o ComfyUI?",
|
||||
"source": "Onde você ouviu falar do ComfyUI?",
|
||||
"usage": "Como você pretende usar o ComfyUI?"
|
||||
},
|
||||
"title": "Pesquisa da Nuvem"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "Saiba mais sobre a Nuvem",
|
||||
"cloudStart_title": "comece a criar em segundos",
|
||||
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
|
||||
"cloudSurvey_steps_experience": "Qual o seu nível de conhecimento do ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "O que você está construindo?",
|
||||
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "O que você deseja criar com o ComfyUI?",
|
||||
"cloudSurvey_steps_source": "Onde você ouviu falar do ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "Em qual plataforma?",
|
||||
"cloudSurvey_steps_usage": "Como você pretende usar o ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "aqui",
|
||||
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Пожалуйста, сократите ваш ответ до {max} символов.",
|
||||
"chooseAnOption": "Пожалуйста, выберите вариант.",
|
||||
"describeAnswer": "Пожалуйста, опишите ваш ответ.",
|
||||
"selectAtLeastOne": "Пожалуйста, выберите хотя бы один вариант."
|
||||
},
|
||||
"intro": "Помогите нам адаптировать ваш опыт работы с ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "Впервые в ComfyUI",
|
||||
"pro": "Я опытный пользователь",
|
||||
"some": "Я немного знаком(а)"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Пользовательские узлы",
|
||||
"pipelines": "Автоматизированные пайплайны",
|
||||
"products": "Продукты для других"
|
||||
"familiarity": {
|
||||
"advanced": "Продвинутый пользователь (пользовательские рабочие процессы)",
|
||||
"basics": "Уверенно владею основами",
|
||||
"expert": "Эксперт (помогаю другим)",
|
||||
"new": "Новичок в ComfyUI (никогда не использовал)",
|
||||
"starting": "Только начинаю (следую руководствам)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Приложения и API",
|
||||
"exploring": "Просто изучаю",
|
||||
"3d_game": "3D-ассеты / игровые ассеты",
|
||||
"api": "API endpoints для запуска workflow",
|
||||
"apps": "Упрощённые приложения из workflow",
|
||||
"audio": "Аудио / музыка",
|
||||
"custom_nodes": "Пользовательские node",
|
||||
"images": "Изображения",
|
||||
"other": "Другое",
|
||||
"otherPlaceholder": "Что вы хотите создать?",
|
||||
"video": "Видео",
|
||||
"not_sure": "Не уверен",
|
||||
"videos": "Видео",
|
||||
"workflows": "Пользовательские workflow или pipeline"
|
||||
},
|
||||
"source": {
|
||||
"community": "Сообщество или форум",
|
||||
"conference": "Конференция или мероприятие",
|
||||
"discord": "Discord / сообщество",
|
||||
"friend": "Друг или коллега",
|
||||
"other": "Другое",
|
||||
"otherPlaceholder": "Где вы о нас узнали?",
|
||||
"search": "Google / поиск",
|
||||
"social": "Социальные сети"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Новостная рассылка или блог",
|
||||
"other": "Другое",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / поиск",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Образование (студент или преподаватель)",
|
||||
"personal": "Личное использование",
|
||||
"work": "Работа"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Расскажите подробнее",
|
||||
"placeholder": "Вопросы для опроса",
|
||||
"steps": {
|
||||
"familiarity": "Насколько вы знакомы с ComfyUI?",
|
||||
"intent": "Что вы хотите создавать с помощью ComfyUI?",
|
||||
"source": "Где вы узнали о ComfyUI?",
|
||||
"usage": "Как вы планируете использовать ComfyUI?"
|
||||
},
|
||||
"title": "Облачный опрос"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "Узнать о Cloud",
|
||||
"cloudStart_title": "начать создавать за секунды",
|
||||
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
|
||||
"cloudSurvey_steps_experience": "Насколько хорошо вы знаете ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "Что вы создаёте?",
|
||||
"cloudSurvey_steps_familiarity": "Насколько вы знакомы с ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "Что вы хотите создавать с помощью ComfyUI?",
|
||||
"cloudSurvey_steps_source": "Где вы узнали о ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "На какой платформе?",
|
||||
"cloudSurvey_steps_usage": "Как вы планируете использовать ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "здесь",
|
||||
"cloudWaitlist_questionsText": "Есть вопросы? Свяжитесь с нами",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Lütfen cevabınızı {max} karakterin altında tutun.",
|
||||
"chooseAnOption": "Lütfen bir seçenek seçin.",
|
||||
"describeAnswer": "Lütfen cevabınızı açıklayın.",
|
||||
"selectAtLeastOne": "Lütfen en az bir seçenek seçin."
|
||||
},
|
||||
"intro": "ComfyUI deneyiminizi size özel hale getirmemize yardımcı olun.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUI'ye yeni",
|
||||
"pro": "Güçlü bir kullanıcıyım",
|
||||
"some": "Biraz biliyorum"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Özel node'lar",
|
||||
"pipelines": "Otomatikleştirilmiş pipeline'lar",
|
||||
"products": "Başkaları için ürünler"
|
||||
"familiarity": {
|
||||
"advanced": "İleri seviye kullanıcı (özel iş akışları)",
|
||||
"basics": "Temel bilgilerde rahatım",
|
||||
"expert": "Uzman (başkalarına yardım ediyorum)",
|
||||
"new": "ComfyUI'a yeni (daha önce hiç kullanmadım)",
|
||||
"starting": "Yeni başlıyorum (eğitimleri takip ediyorum)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Uygulamalar ve API'ler",
|
||||
"exploring": "Sadece keşfediyorum",
|
||||
"3d_game": "3D varlıklar / oyun varlıkları",
|
||||
"api": "İş akışlarını çalıştırmak için API uç noktaları",
|
||||
"apps": "İş akışlarından basitleştirilmiş uygulamalar",
|
||||
"audio": "Ses / müzik",
|
||||
"custom_nodes": "Özel node'lar",
|
||||
"images": "Görseller",
|
||||
"other": "Başka bir şey",
|
||||
"otherPlaceholder": "Ne yapmak istiyorsunuz?",
|
||||
"video": "Video",
|
||||
"not_sure": "Emin değilim",
|
||||
"videos": "Videolar",
|
||||
"workflows": "Özel iş akışları veya boru hatları"
|
||||
},
|
||||
"source": {
|
||||
"community": "Bir topluluk veya forum",
|
||||
"conference": "Konferans veya etkinlik",
|
||||
"discord": "Discord / topluluk",
|
||||
"friend": "Arkadaş veya iş arkadaşı",
|
||||
"other": "Diğer",
|
||||
"otherPlaceholder": "Bizi nereden buldunuz?",
|
||||
"search": "Google / arama",
|
||||
"social": "Sosyal medya"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Bülten veya blog",
|
||||
"other": "Diğer",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / arama",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Eğitim (öğrenci veya eğitmen)",
|
||||
"personal": "Kişisel kullanım",
|
||||
"work": "İş"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Daha fazla bilgi verin",
|
||||
"placeholder": "Anket soruları yer tutucusu",
|
||||
"steps": {
|
||||
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
|
||||
"intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
|
||||
"source": "ComfyUI'yi nereden duydunuz?",
|
||||
"usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?"
|
||||
},
|
||||
"title": "Bulut Anketi"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "Cloud hakkında bilgi edinin",
|
||||
"cloudStart_title": "saniyeler içinde oluşturmaya başlayın",
|
||||
"cloudStart_wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?",
|
||||
"cloudSurvey_steps_experience": "ComfyUI'yi ne kadar iyi biliyorsunuz?",
|
||||
"cloudSurvey_steps_focus": "Ne inşa ediyorsunuz?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
|
||||
"cloudSurvey_steps_intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
|
||||
"cloudSurvey_steps_source": "ComfyUI'yi nereden duydunuz?",
|
||||
"cloudSurvey_steps_source_social": "Hangi platform?",
|
||||
"cloudSurvey_steps_usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?",
|
||||
"cloudWaitlist_contactLink": "burada",
|
||||
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "請將您的回答控制在 {max} 個字以內。",
|
||||
"chooseAnOption": "請選擇一個選項。",
|
||||
"describeAnswer": "請描述您的答案。",
|
||||
"selectAtLeastOne": "請至少選擇一個選項。"
|
||||
},
|
||||
"intro": "協助我們為您量身打造 ComfyUI 體驗。",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUI 新手",
|
||||
"pro": "我是進階使用者",
|
||||
"some": "我已經熟悉操作"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "自訂節點",
|
||||
"pipelines": "自動化流程",
|
||||
"products": "為他人打造產品"
|
||||
"familiarity": {
|
||||
"advanced": "進階使用者(自訂工作流程)",
|
||||
"basics": "熟悉基礎操作",
|
||||
"expert": "專家(協助他人)",
|
||||
"new": "ComfyUI 新手(從未使用過)",
|
||||
"starting": "剛開始(正在跟隨教學)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "應用程式與 API",
|
||||
"exploring": "只是探索",
|
||||
"3d_game": "3D 素材/遊戲素材",
|
||||
"api": "執行工作流程的 API 端點",
|
||||
"apps": "由工作流程簡化的應用程式",
|
||||
"audio": "音訊/音樂",
|
||||
"custom_nodes": "自訂節點",
|
||||
"images": "圖像",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "您想製作什麼?",
|
||||
"video": "影片",
|
||||
"not_sure": "尚未確定",
|
||||
"videos": "影片",
|
||||
"workflows": "自訂工作流程或管線"
|
||||
},
|
||||
"source": {
|
||||
"community": "社群或論壇",
|
||||
"conference": "研討會或活動",
|
||||
"discord": "Discord/社群",
|
||||
"friend": "朋友或同事",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "您是在哪裡發現我們的?",
|
||||
"search": "Google/搜尋引擎",
|
||||
"social": "社群媒體"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "電子報或部落格",
|
||||
"other": "其他",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X(Twitter)",
|
||||
"search": "Google/搜尋引擎",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "教育用途(學生或教育者)",
|
||||
"personal": "個人用途",
|
||||
"work": "工作用途"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "請告訴我們更多",
|
||||
"placeholder": "問卷問題佔位符",
|
||||
"steps": {
|
||||
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
||||
"intent": "您想用 ComfyUI 創作什麼?",
|
||||
"source": "您是從哪裡得知 ComfyUI 的?",
|
||||
"usage": "您打算如何使用 ComfyUI?"
|
||||
},
|
||||
"title": "雲端問卷"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "了解雲端服務",
|
||||
"cloudStart_title": "數秒內開始創作",
|
||||
"cloudStart_wantToRun": "想要在本機運行 ComfyUI?",
|
||||
"cloudSurvey_steps_experience": "您對 ComfyUI 的熟悉程度?",
|
||||
"cloudSurvey_steps_focus": "您正在製作什麼?",
|
||||
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
||||
"cloudSurvey_steps_intent": "您想用 ComfyUI 創作什麼?",
|
||||
"cloudSurvey_steps_source": "您是從哪裡得知 ComfyUI 的?",
|
||||
"cloudSurvey_steps_source_social": "哪個平台?",
|
||||
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "此處",
|
||||
"cloudWaitlist_questionsText": "有問題?聯絡我們",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "请将您的回答控制在 {max} 个字符以内。",
|
||||
"chooseAnOption": "请选择一个选项。",
|
||||
"describeAnswer": "请描述您的答案。",
|
||||
"selectAtLeastOne": "请至少选择一个选项。"
|
||||
},
|
||||
"intro": "帮助我们为您定制 ComfyUI 体验。",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUI 新手",
|
||||
"pro": "我是高级用户",
|
||||
"some": "我已经熟悉操作"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "自定义节点",
|
||||
"pipelines": "自动化流程",
|
||||
"products": "为他人制作产品"
|
||||
"familiarity": {
|
||||
"advanced": "高级用户(自定义工作流)",
|
||||
"basics": "熟练掌握基础知识",
|
||||
"expert": "专家(帮助他人)",
|
||||
"new": "ComfyUI 新手(从未使用过)",
|
||||
"starting": "刚刚开始(正在学习教程)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "应用和 API",
|
||||
"exploring": "只是探索一下",
|
||||
"3d_game": "3D 资产 / 游戏资产",
|
||||
"api": "运行工作流的 API 端点",
|
||||
"apps": "基于工作流的简化应用",
|
||||
"audio": "音频 / 音乐",
|
||||
"custom_nodes": "自定义节点",
|
||||
"images": "图像",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "你想做什么?",
|
||||
"video": "视频",
|
||||
"not_sure": "不确定",
|
||||
"videos": "视频",
|
||||
"workflows": "自定义工作流或流程"
|
||||
},
|
||||
"source": {
|
||||
"community": "社区或论坛",
|
||||
"conference": "会议或活动",
|
||||
"discord": "Discord / 社区",
|
||||
"friend": "朋友或同事",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "你是从哪里了解到我们的?",
|
||||
"search": "Google / 搜索",
|
||||
"social": "社交媒体"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "新闻通讯或博客",
|
||||
"other": "其他",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X(推特)",
|
||||
"search": "Google / 搜索",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "教育(学生或教师)",
|
||||
"personal": "个人使用",
|
||||
"work": "工作"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "请告诉我们更多",
|
||||
"placeholder": "调查问题占位符",
|
||||
"steps": {
|
||||
"familiarity": "你对 ComfyUI 有多熟悉?",
|
||||
"intent": "您希望用 ComfyUI 创作什么?",
|
||||
"source": "您是从哪里了解到 ComfyUI 的?",
|
||||
"usage": "您打算如何使用 ComfyUI?"
|
||||
},
|
||||
"title": "云调研"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "了解云服务",
|
||||
"cloudStart_title": "几秒钟内开始创作",
|
||||
"cloudStart_wantToRun": "想在本地运行 ComfyUI 吗?",
|
||||
"cloudSurvey_steps_experience": "你对 ComfyUI 有多了解?",
|
||||
"cloudSurvey_steps_focus": "你正在构建什么?",
|
||||
"cloudSurvey_steps_familiarity": "你对 ComfyUI 有多熟悉?",
|
||||
"cloudSurvey_steps_intent": "您希望用 ComfyUI 创作什么?",
|
||||
"cloudSurvey_steps_source": "您是从哪里了解到 ComfyUI 的?",
|
||||
"cloudSurvey_steps_source_social": "你是在哪个平台上看到的?",
|
||||
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "这里",
|
||||
"cloudWaitlist_questionsText": "有问题?联系我们",
|
||||
"color": {
|
||||
|
||||
@@ -1,67 +1,48 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
data-testid="assets-selection-bar"
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
<SelectionBar
|
||||
data-testid="assets-selection-bar"
|
||||
:label="$t('mediaAsset.selection.selectedCount', { count })"
|
||||
:deselect-label="$t('mediaAsset.selection.deselectAll')"
|
||||
@deselect="emit('deselect')"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deselectAll'),
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-deselect-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deselectAll')"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SelectionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SelectionBar from '@/components/common/SelectionBar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { count, showDelete = true } = defineProps<{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="dark-theme flex max-h-full w-full max-w-md flex-col px-4 sm:px-6">
|
||||
<div
|
||||
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
|
||||
>
|
||||
<h1
|
||||
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
|
||||
@@ -1,618 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
/**
|
||||
* Every test drives a real in-memory router and the real preserved-query
|
||||
* manager: the tracker strips the code from the URL at capture time, so the
|
||||
* stash is the only carrier, and redemption fires from router.afterEach, an
|
||||
* auth watcher, and a delayed retry after a transient failure.
|
||||
*
|
||||
* The fake clock (installed for every test) keeps those retry timers from
|
||||
* leaking into later tests: afterEach discards them with vi.useRealTimers().
|
||||
*/
|
||||
|
||||
const mockConfirm = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
confirm: mockConfirm
|
||||
})
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
interface MockAuthStore {
|
||||
currentUser: {
|
||||
uid: string
|
||||
getIdToken: (forceRefresh?: boolean) => Promise<string>
|
||||
} | null
|
||||
getIdToken: () => Promise<string>
|
||||
}
|
||||
|
||||
const mockUserGetIdToken = vi.hoisted(() => vi.fn())
|
||||
const mockStoreGetIdToken = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Reactive so the module's watcher on currentUser fires without a navigation.
|
||||
// The mock factory is cached across vi.resetModules(), so it reads a holder
|
||||
// refilled per test; watchers leaked by earlier module generations stay
|
||||
// subscribed to earlier stores and remain dormant.
|
||||
const authStoreHolder = vi.hoisted(() => ({
|
||||
store: null as MockAuthStore | null
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => authStoreHolder.store
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `/api${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
const VALID_CODE = `dlc_${'A'.repeat(43)}`
|
||||
const SECOND_CODE = `dlc_${'B'.repeat(43)}`
|
||||
const REDEEM_URL = '/api/auth/desktop-login-codes/redeem'
|
||||
const NAMESPACE = 'desktop_login'
|
||||
const STORAGE_KEY = 'Comfy.PreservedQuery.desktop_login'
|
||||
const RETRY_DELAY_MS = 5_000
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
let mockAuthStore: MockAuthStore
|
||||
|
||||
function okResponse() {
|
||||
return new Response(JSON.stringify({ status: 'redeemed' }), { status: 200 })
|
||||
}
|
||||
|
||||
function expectedFetchOptions(code: string) {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer firebase-id-token',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
signal: expect.any(AbortSignal)
|
||||
}
|
||||
}
|
||||
|
||||
// The triggers fire-and-forget the redemption; a zero-length advance of the
|
||||
// fake clock yields the event loop so the whole mocked promise chain settles.
|
||||
async function flushRedemption() {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
// vi.resetModules() also resets the preserved-query manager's in-memory map,
|
||||
// so the manager must be imported alongside the module under test.
|
||||
async function setup() {
|
||||
const { installDesktopLoginRedemption } =
|
||||
await import('./desktopLoginRedemption')
|
||||
const { capturePreservedQuery, getPreservedQueryParam } =
|
||||
await import('@/platform/navigation/preservedQueryManager')
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
installDesktopLoginRedemption(router)
|
||||
|
||||
let navigationCount = 0
|
||||
const trigger = async () => {
|
||||
await router.push(`/trigger-${navigationCount++}`)
|
||||
await flushRedemption()
|
||||
}
|
||||
|
||||
return {
|
||||
router,
|
||||
trigger,
|
||||
seedStash: (code: string) =>
|
||||
capturePreservedQuery(NAMESPACE, { desktop_login_code: code }, [
|
||||
'desktop_login_code'
|
||||
]),
|
||||
stashedCode: () => getPreservedQueryParam(NAMESPACE, 'desktop_login_code')
|
||||
}
|
||||
}
|
||||
|
||||
describe('installDesktopLoginRedemption', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
sessionStorage.clear()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockFetch.mockReset()
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
mockUserGetIdToken.mockResolvedValue('firebase-id-token')
|
||||
mockAuthStore = reactive({
|
||||
currentUser: {
|
||||
uid: 'user-1',
|
||||
getIdToken: mockUserGetIdToken
|
||||
},
|
||||
getIdToken: mockStoreGetIdToken
|
||||
})
|
||||
authStoreHolder.store = mockAuthStore
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing on navigation when no code is stashed', async () => {
|
||||
const { trigger } = await setup()
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redeems a stashed code once on navigation with the Firebase bearer token after approval', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockConfirm).toHaveBeenCalledWith({
|
||||
title: 'desktopLogin.confirmSummary',
|
||||
message: 'desktopLogin.confirmMessage'
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'desktopLogin.successSummary',
|
||||
detail: 'desktopLogin.successDetail',
|
||||
life: 4000
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fetch before the user approves the confirmation dialog', async () => {
|
||||
const { trigger, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let approve!: (value: boolean) => void
|
||||
mockConfirm.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
approve = resolve
|
||||
})
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
|
||||
approve(true)
|
||||
await flushRedemption()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['declines', false],
|
||||
['dismisses', null]
|
||||
] as const)(
|
||||
'clears the stash without a request or toast when the user %s the dialog',
|
||||
async ([_label, confirmResult]) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockConfirm.mockResolvedValue(confirmResult)
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
|
||||
// Declining is final for that code: re-capturing it never re-prompts.
|
||||
seedStash(VALID_CODE)
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('asks for approval at most once per code across transient retries', async () => {
|
||||
const { trigger, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('redeems a code hydrated lazily from sessionStorage', async () => {
|
||||
const { trigger } = await setup()
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ desktop_login_code: VALID_CODE })
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not redeem or prompt again after a successful redemption', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A later navigation re-captures the already-redeemed code.
|
||||
seedStash(VALID_CODE)
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([400, 403, 404, 409, 410])(
|
||||
'clears the stash, shows an error toast, and never retries on %s',
|
||||
async (status) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status }))
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'desktopLogin.expiredSummary',
|
||||
detail: 'desktopLogin.expiredDetail',
|
||||
life: 6000
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.for([401, 500])(
|
||||
'keeps the stash on %s for the scheduled retry without a toast',
|
||||
async (status) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status }))
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('retries once by itself, then clears the stash and shows an error toast when the budget is spent', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'desktopLogin.failedSummary',
|
||||
detail: 'desktopLogin.failedDetail',
|
||||
life: 6000
|
||||
})
|
||||
})
|
||||
|
||||
it('forces a token refresh on the retry after a 401', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockResolvedValueOnce(okResponse())
|
||||
|
||||
await trigger()
|
||||
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(true)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a timeout signal and treats an aborted request as transient', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockRejectedValue(
|
||||
new DOMException('The operation timed out.', 'TimeoutError')
|
||||
)
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an id token failure as transient without a toast', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockUserGetIdToken.mockRejectedValue(new Error('firebase unavailable'))
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
// authStore.getIdToken surfaces failures through a modal error dialog,
|
||||
// which this background flow must never trigger.
|
||||
expect(mockStoreGetIdToken).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the stash without a dialog or request for a malformed code', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash('not-a-desktop-login-code')
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains an unexpected internal error instead of rejecting', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { trigger, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockConfirm.mockRejectedValue(new Error('dialog exploded'))
|
||||
|
||||
await expect(trigger()).resolves.toBeUndefined()
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[DesktopLoginRedemption] Redemption failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the stash while unauthenticated and redeems via the auth watcher once a session appears', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockAuthStore.currentUser = null
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
// The first completed navigation installs the watcher; without a session
|
||||
// nothing redeems and the stash is kept.
|
||||
await trigger()
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
|
||||
// A session appearing without any further navigation redeems via the
|
||||
// watcher.
|
||||
mockAuthStore.currentUser = {
|
||||
uid: 'user-1',
|
||||
getIdToken: mockUserGetIdToken
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([
|
||||
['succeeded', () => mockFetch.mockResolvedValueOnce(okResponse())],
|
||||
['was declined', () => mockConfirm.mockResolvedValueOnce(false)]
|
||||
] as const)(
|
||||
'gives a second code its own dialog and request after the first code %s',
|
||||
async ([_label, arrangeFirstOutcome]) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
arrangeFirstOutcome()
|
||||
|
||||
await trigger()
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
|
||||
seedStash(SECOND_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('gives a second code a fresh attempt budget after the first code exhausted its own', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await trigger()
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
|
||||
seedStash(SECOND_CODE)
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
expect(stashedCode()).toBe(SECOND_CODE)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-asks for approval when the account changes after approval and redeems with the new account token', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
.mockResolvedValueOnce(okResponse())
|
||||
|
||||
// user-1 approves; the redeem fails transiently, keeping the code stashed.
|
||||
await trigger()
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
|
||||
// The session changes to user-2 before the retry: user-1's approval must
|
||||
// not authorize redeeming with user-2's token.
|
||||
mockAuthStore.currentUser = {
|
||||
uid: 'user-2',
|
||||
getIdToken: vi.fn().mockResolvedValue('second-user-token')
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
|
||||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer second-user-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-prompts and redeems under the new account when the session changes while the approval dialog is open', async () => {
|
||||
const { seedStash, stashedCode, trigger } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let approve!: (value: boolean) => void
|
||||
mockConfirm.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
approve = resolve
|
||||
})
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
|
||||
|
||||
// The session swaps to user-2 while user-1's dialog is open: the stale
|
||||
// approval must not redeem, and the raced auth trigger is replayed to
|
||||
// re-prompt under user-2 without another navigation.
|
||||
const secondUser = {
|
||||
uid: 'user-2',
|
||||
getIdToken: vi.fn().mockResolvedValue('second-user-token')
|
||||
}
|
||||
mockAuthStore.currentUser = secondUser
|
||||
await flushRedemption()
|
||||
approve(true)
|
||||
await flushRedemption()
|
||||
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer second-user-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([
|
||||
['succeeds', () => okResponse()],
|
||||
['fails terminally', () => new Response(null, { status: 404 })]
|
||||
] as const)(
|
||||
'processes a newer code stashed mid-flight after the older redemption %s',
|
||||
async ([_label, firstResponse]) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let resolveFirstFetch!: (response: Response) => void
|
||||
mockFetch.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirstFetch = resolve
|
||||
})
|
||||
)
|
||||
|
||||
await trigger()
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
|
||||
// A second code arrives while the first redemption is in flight; it
|
||||
// must survive the first's settlement and be processed right after.
|
||||
seedStash(SECOND_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
resolveFirstFetch(firstResponse())
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('coalesces concurrent triggers into one dialog and one request', async () => {
|
||||
const { router, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let approve!: (value: boolean) => void
|
||||
mockConfirm.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
approve = resolve
|
||||
})
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await router.push('/burst-1')
|
||||
await router.push('/burst-2')
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
|
||||
|
||||
approve(true)
|
||||
await flushRedemption()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,263 +0,0 @@
|
||||
import { watch } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
getPreservedQueryParam
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN
|
||||
const DESKTOP_LOGIN_CODE_KEY = 'desktop_login_code'
|
||||
|
||||
// The backend issues "dlc_" + 43 base64url chars; bounds are loose so the
|
||||
// backend stays the authority on exact code length.
|
||||
const DESKTOP_LOGIN_CODE_PATTERN = /^dlc_[A-Za-z0-9_-]{20,256}$/
|
||||
|
||||
// Statuses that mean the desktop app must start a fresh sign-in, so the code
|
||||
// is dropped. 401 stays transient: the session may still be settling.
|
||||
const TERMINAL_REDEEM_STATUSES = new Set([400, 403, 404, 409, 410])
|
||||
|
||||
// One delayed in-page retry, so an approved sign-in always reaches a success
|
||||
// or failure toast without ever looping within a page load.
|
||||
const MAX_REDEEM_ATTEMPTS = 2
|
||||
const RETRY_DELAY_MS = 5_000
|
||||
|
||||
// Abort the redeem request if the backend hangs; treated as transient.
|
||||
const REDEEM_TIMEOUT_MS = 10_000
|
||||
|
||||
interface CodeRedemptionState {
|
||||
attempts: number
|
||||
approvedUserUid: string | null
|
||||
settled: boolean
|
||||
forceTokenRefresh: boolean
|
||||
}
|
||||
|
||||
// Keyed by code so a different code arriving later gets its own approval and
|
||||
// attempt budget, while retries of the same code reuse both.
|
||||
const codeStates = new Map<string, CodeRedemptionState>()
|
||||
|
||||
// Coalesces concurrent triggers into one drain; a trigger arriving mid-drain
|
||||
// (e.g. the auth watcher firing while the dialog is open) is replayed as one
|
||||
// more pass instead of being dropped.
|
||||
let draining = false
|
||||
let retriggerRequested = false
|
||||
|
||||
let authWatcherInstalled = false
|
||||
|
||||
function getCodeState(code: string): CodeRedemptionState {
|
||||
const existing = codeStates.get(code)
|
||||
if (existing) return existing
|
||||
const fresh = {
|
||||
attempts: 0,
|
||||
approvedUserUid: null,
|
||||
settled: false,
|
||||
forceTokenRefresh: false
|
||||
}
|
||||
codeStates.set(code, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
// A newer code can be stashed while an older one is mid-redemption; settling
|
||||
// the older one must not wipe it.
|
||||
function clearStashIfHolds(code: string): void {
|
||||
if (getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY) === code) {
|
||||
clearPreservedQuery(NAMESPACE)
|
||||
}
|
||||
}
|
||||
|
||||
function settle(code: string, state: CodeRedemptionState): void {
|
||||
state.settled = true
|
||||
clearStashIfHolds(code)
|
||||
}
|
||||
|
||||
function handleTransientFailure(
|
||||
code: string,
|
||||
state: CodeRedemptionState,
|
||||
reason: string
|
||||
): void {
|
||||
console.warn(`[DesktopLoginRedemption] Redeem request failed: ${reason}`)
|
||||
if (state.attempts < MAX_REDEEM_ATTEMPTS) {
|
||||
// attempts only increments, so this branch runs at most once per code
|
||||
// and cannot stack retry timers.
|
||||
setTimeout(() => {
|
||||
void redeemPendingDesktopLoginCode()
|
||||
}, RETRY_DELAY_MS)
|
||||
return
|
||||
}
|
||||
// Budget spent: drop the code and tell the user instead of failing silently.
|
||||
settle(code, state)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('desktopLogin.failedSummary'),
|
||||
detail: t('desktopLogin.failedDetail'),
|
||||
life: 6000
|
||||
})
|
||||
}
|
||||
|
||||
// Explicit approval defeats device-code phishing: a lured click on a leaked
|
||||
// link must not bind the victim's session to an attacker's desktop app.
|
||||
// Approval is per code *and* account.
|
||||
async function confirmRedemption(
|
||||
state: CodeRedemptionState,
|
||||
uid: string
|
||||
): Promise<boolean> {
|
||||
if (state.approvedUserUid === uid) return true
|
||||
const confirmed = await useDialogService().confirm({
|
||||
title: t('desktopLogin.confirmSummary'),
|
||||
message: t('desktopLogin.confirmMessage')
|
||||
})
|
||||
if (confirmed !== true) return false
|
||||
state.approvedUserUid = uid
|
||||
return true
|
||||
}
|
||||
|
||||
async function redeemCode(code: string): Promise<void> {
|
||||
const state = getCodeState(code)
|
||||
if (state.settled) {
|
||||
// A later navigation can re-capture an already-settled code; drop it.
|
||||
clearStashIfHolds(code)
|
||||
return
|
||||
}
|
||||
|
||||
// No session yet (e.g. code captured on the login page): keep the stash and
|
||||
// let a post-login trigger redeem it.
|
||||
const user = useAuthStore().currentUser
|
||||
if (!user) return
|
||||
|
||||
if (!(await confirmRedemption(state, user.uid))) {
|
||||
// Declined/dismissed: drop the code without an error.
|
||||
settle(code, state)
|
||||
return
|
||||
}
|
||||
|
||||
// Approval binds the code to one account: if the session changed while the
|
||||
// dialog was open, keep the code stashed and let the (replayed) auth-change
|
||||
// trigger re-prompt under the now-current account.
|
||||
const approvedUser = useAuthStore().currentUser
|
||||
if (!approvedUser || approvedUser.uid !== state.approvedUserUid) return
|
||||
|
||||
state.attempts++
|
||||
|
||||
// Token comes straight from the Firebase user: authStore.getIdToken()
|
||||
// surfaces failures through a modal dialog this background flow must avoid.
|
||||
let idToken: string
|
||||
try {
|
||||
idToken = await approvedUser.getIdToken(state.forceTokenRefresh)
|
||||
} catch {
|
||||
handleTransientFailure(code, state, 'could not get id token')
|
||||
return
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(api.apiURL('/auth/desktop-login-codes/redeem'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${idToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
// TODO(@comfyorg/ingest-types): type the payload with the generated
|
||||
// request type once the desktop-login-codes openapi addition propagates.
|
||||
body: JSON.stringify({ code }),
|
||||
signal: AbortSignal.timeout(REDEEM_TIMEOUT_MS)
|
||||
})
|
||||
} catch (error) {
|
||||
handleTransientFailure(
|
||||
code,
|
||||
state,
|
||||
error instanceof Error && error.name === 'TimeoutError'
|
||||
? 'request timed out'
|
||||
: 'network error'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
settle(code, state)
|
||||
useToastStore().add({
|
||||
severity: 'success',
|
||||
summary: t('desktopLogin.successSummary'),
|
||||
detail: t('desktopLogin.successDetail'),
|
||||
life: 4000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (TERMINAL_REDEEM_STATUSES.has(response.status)) {
|
||||
settle(code, state)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('desktopLogin.expiredSummary'),
|
||||
detail: t('desktopLogin.expiredDetail'),
|
||||
life: 6000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// A 401 usually means a stale cached id token; mint a fresh one on retry.
|
||||
if (response.status === 401) state.forceTokenRefresh = true
|
||||
handleTransientFailure(code, state, `status ${response.status}`)
|
||||
}
|
||||
|
||||
async function redeemPendingDesktopLoginCode(): Promise<void> {
|
||||
// Never rejects: the triggers fire-and-forget this.
|
||||
if (draining) {
|
||||
retriggerRequested = true
|
||||
return
|
||||
}
|
||||
draining = true
|
||||
try {
|
||||
do {
|
||||
retriggerRequested = false
|
||||
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
|
||||
if (!code) continue
|
||||
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
|
||||
clearPreservedQuery(NAMESPACE)
|
||||
continue
|
||||
}
|
||||
await redeemCode(code)
|
||||
if (code !== getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY))
|
||||
retriggerRequested = true
|
||||
} while (retriggerRequested)
|
||||
} catch (error) {
|
||||
console.error('[DesktopLoginRedemption] Redemption failed:', error)
|
||||
} finally {
|
||||
draining = false
|
||||
}
|
||||
}
|
||||
|
||||
function installAuthWatcherOnce(): void {
|
||||
if (authWatcherInstalled) return
|
||||
authWatcherInstalled = true
|
||||
// A session can appear without a navigation (e.g. dialog-based sign-in).
|
||||
// Installed lazily because pinia is not active when router.ts evaluates.
|
||||
watch(
|
||||
() => useAuthStore().currentUser,
|
||||
() => {
|
||||
void redeemPendingDesktopLoginCode()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems desktop login codes (`?desktop_login_code=dlc_...`).
|
||||
*
|
||||
* The desktop app opens the browser with an opaque one-time code and polls
|
||||
* the cloud backend; redeeming the code from a signed-in browser session,
|
||||
* with the user's approval, releases a one-time custom token to that poll
|
||||
* and signs the desktop app in. The preserved-query tracker (configured in
|
||||
* router.ts) strips the code from the URL at capture time, so the stash is
|
||||
* the only place it lives.
|
||||
*/
|
||||
export function installDesktopLoginRedemption(router: Router): void {
|
||||
router.afterEach(() => {
|
||||
installAuthWatcherOnce()
|
||||
void redeemPendingDesktopLoginCode()
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="max-h-[45vh] overflow-y-auto transition-[height] duration-300 ease-out sm:max-h-[55vh]"
|
||||
class="overflow-hidden transition-[height] duration-300 ease-out"
|
||||
:style="animatedHeightStyle"
|
||||
>
|
||||
<div ref="questionContent" class="relative">
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
balance: computed(() => state.balance),
|
||||
subscription: computed(() => state.subscription),
|
||||
isPaused: computed(() => false),
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isFreeTier: computed(() => state.isFreeTier),
|
||||
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
|
||||
@@ -97,24 +98,14 @@ const i18n = createI18n({
|
||||
remaining: 'remaining',
|
||||
refreshCredits: 'Refresh credits',
|
||||
monthly: 'Monthly',
|
||||
refillsDate: 'Refills {date}',
|
||||
refillsNextCycle: 'Refills next cycle',
|
||||
creditsUsed: '{used} used',
|
||||
creditsLeftOfTotal: '{remaining} left of {total}',
|
||||
monthlyUsageProgress: '{used} of {total} monthly credits used',
|
||||
yearly: 'Yearly',
|
||||
percentUsed: '{percent}% used',
|
||||
usageProgress: '{used} of {total} credits used',
|
||||
additionalCreditsInfo: 'About additional credits',
|
||||
additionalCreditsTooltip: 'Credits you add on top of your plan.',
|
||||
additionalCredits: 'Additional credits',
|
||||
additionalCreditsInUse: 'In use',
|
||||
usedAfterMonthly: 'Used after monthly runs out',
|
||||
monthlyCreditsUsedUpTitle:
|
||||
'Monthly credits are used up. Refills {date}',
|
||||
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
|
||||
monthlyCreditsUsedUpDescription:
|
||||
"You're now spending additional credits.",
|
||||
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
|
||||
outOfCreditsTitleNoDate: "You're out of credits",
|
||||
outOfCreditsDescription: 'Add more credits to continue generating.',
|
||||
usedAfterMonthly: 'Used after plan credits run out',
|
||||
addCredits: 'Add credits',
|
||||
upgradeToAddCredits: 'Upgrade to add credits'
|
||||
}
|
||||
@@ -178,27 +169,19 @@ describe('CreditsTile', () => {
|
||||
it('renders the monthly usage bar and additional breakdown', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678 -> 98%.
|
||||
expect(container.textContent).toContain('Monthly')
|
||||
expect(container.textContent).toMatch(/Refills Feb/)
|
||||
expect(container.textContent).toContain('20,678 used')
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).toContain('98% used')
|
||||
expect(container.textContent).toContain('Additional credits')
|
||||
expect(container.textContent).toContain('633')
|
||||
expect(container.textContent).toContain('Used after monthly runs out')
|
||||
expect(container.textContent).toContain('Used after plan credits run out')
|
||||
})
|
||||
|
||||
it('renders a compact monthly summary for narrow containers', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('422 left of 21K')
|
||||
})
|
||||
|
||||
it('uses the team credit stop monthly grant for the monthly total', () => {
|
||||
it('uses the team credit stop grant for a monthly allowance', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.currentTeamCreditStop = {
|
||||
@@ -207,13 +190,15 @@ describe('CreditsTile', () => {
|
||||
stop_usd: 2500
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Monthly total is the stop's raw monthly grant, not the tier fallback,
|
||||
// and is not multiplied by 12 for annual billing.
|
||||
expect(container.textContent).toContain('422 left of 527,500')
|
||||
renderTile()
|
||||
// Allowance is the stop's grant, not the tier fallback.
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'527500'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the per-month nominal grant for an annual personal tier', () => {
|
||||
it('grants the full year upfront for an annual plan', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
@@ -221,35 +206,25 @@ describe('CreditsTile', () => {
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Annual billing still grants the monthly nominal (21,100), not 12x.
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).not.toContain('253,200')
|
||||
renderTile()
|
||||
// Annual plans grant the whole year at once: 21,100 x 12.
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'253200'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to a dateless refills label when renewal date is missing', () => {
|
||||
activeProSubscription()
|
||||
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('Refills next cycle')
|
||||
expect(container.textContent).not.toContain('Refills Feb')
|
||||
})
|
||||
|
||||
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
|
||||
activeProSubscription()
|
||||
it('labels the allowance by billing duration (yearly for annual)', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: 'not-a-date'
|
||||
duration: 'ANNUAL',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain("You're out of credits")
|
||||
expect(container.textContent).not.toContain('Credits refill')
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
renderTile()
|
||||
expect(screen.getByText('Yearly')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Monthly')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the breakdown and forces zeros in the zero state', () => {
|
||||
@@ -271,11 +246,9 @@ describe('CreditsTile', () => {
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
|
||||
it('shows no in-use badge while monthly credits remain', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -286,42 +259,29 @@ describe('CreditsTile', () => {
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 300
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
'Monthly credits are used up. Refills Feb 20'
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
"You're now spending additional credits."
|
||||
)
|
||||
renderTile()
|
||||
expect(screen.getByText('In use')).toBeTruthy()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('tertiary')
|
||||
})
|
||||
|
||||
it('emphasizes add-credits when fully out of credits', () => {
|
||||
it('emphasizes add-credits when fully out of credits, without a punch-out notice', () => {
|
||||
activeProSubscription()
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
"You're out of credits. Credits refill Feb 20"
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
'Add more credits to continue generating.'
|
||||
)
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
|
||||
})
|
||||
|
||||
it('suppresses the depletion notice until the balance has loaded', () => {
|
||||
it('shows no in-use badge until the balance has loaded', () => {
|
||||
activeProSubscription()
|
||||
state.balance = null
|
||||
state.isLoading = true
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes add-credits through telemetry + the top-up dialog', async () => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
|
||||
:class="
|
||||
cn(
|
||||
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5 transition-opacity',
|
||||
// Paused subscriptions can't spend credits, so dim the whole tile to
|
||||
// read as frozen and defer to the Update-payment banner. A lapsed plan
|
||||
// (frozen) reads the same way.
|
||||
(isPaused || frozen) && 'opacity-50',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -19,8 +28,10 @@
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="flex items-baseline gap-2">
|
||||
<i class="icon-[lucide--component] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
|
||||
<i class="icon-[lucide--coins] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold tabular-nums">{{
|
||||
displayTotal
|
||||
}}</span>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">{{
|
||||
$t('subscription.remaining')
|
||||
}}</span>
|
||||
@@ -28,37 +39,22 @@
|
||||
</div>
|
||||
|
||||
<template v-if="showBreakdown">
|
||||
<div
|
||||
v-if="emptyStateNotice"
|
||||
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
|
||||
<span class="text-muted">{{ emptyStateNotice.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showBar"
|
||||
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
|
||||
:class="cn('flex flex-col gap-2', isAllowanceDepleted && 'opacity-30')"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-text-primary">{{
|
||||
$t('subscription.monthly')
|
||||
}}</span>
|
||||
<span class="text-muted">{{ cycleLabel }}</span>
|
||||
<span class="text-muted">
|
||||
{{ refillsLabel }}
|
||||
{{ cycleStatusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
:aria-valuenow="usage.used"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="monthlyTotalCredits ?? 0"
|
||||
:aria-valuetext="monthlyUsageLabel"
|
||||
:aria-valuemax="allowanceTotalCredits ?? 0"
|
||||
:aria-valuetext="cycleUsageLabel"
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
@@ -66,40 +62,6 @@
|
||||
:style="{ width: usedBarWidth }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
class="@max-[300px]:hidden"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else class="text-muted @max-[300px]:hidden">
|
||||
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
|
||||
</span>
|
||||
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<span class="@max-[180px]:hidden">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyBonusCredits,
|
||||
total: monthlyTotalDisplay
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="hidden @max-[180px]:inline">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyRemainingCompact,
|
||||
total: monthlyTotalCompact
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px w-full bg-interface-stroke" />
|
||||
@@ -118,7 +80,7 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('subscription.additionalCreditsInfo')"
|
||||
class="text-muted"
|
||||
class="flex cursor-help appearance-none items-center border-none bg-transparent p-0 text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
@@ -132,9 +94,9 @@
|
||||
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
{{ displayPrepaid }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -156,15 +118,10 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
|
||||
:variant="isOutOfCredits ? 'inverted' : 'tertiary'"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'w-full font-normal',
|
||||
!isOutOfCredits &&
|
||||
'bg-interface-menu-component-surface-selected text-text-primary'
|
||||
)
|
||||
"
|
||||
class="w-full font-normal"
|
||||
:disabled="isPaused || frozen"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
@@ -178,6 +135,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
@@ -186,40 +144,45 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { zeroState = false } = defineProps<{
|
||||
const {
|
||||
zeroState = false,
|
||||
frozen = false,
|
||||
class: customClass
|
||||
} = defineProps<{
|
||||
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
|
||||
zeroState?: boolean
|
||||
/**
|
||||
* Renders the full breakdown but dimmed and non-interactive, for a lapsed
|
||||
* subscription that still has a shape to show. Mirrors the paused treatment.
|
||||
*/
|
||||
frozen?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const {
|
||||
subscription,
|
||||
isPaused,
|
||||
balance,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
currentTeamCreditStop,
|
||||
fetchBalance,
|
||||
fetchStatus
|
||||
} = useBillingContext()
|
||||
const {
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
totalCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
isLoadingBalance,
|
||||
allowanceTotalCredits,
|
||||
usage
|
||||
} = useSubscriptionCredits()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
@@ -227,40 +190,18 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
|
||||
const monthlyTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = currentTeamCreditStop.value
|
||||
if (teamStop) return teamStop.credits_monthly
|
||||
return getTierCredits(tierKey.value)
|
||||
})
|
||||
|
||||
const usage = computed(() =>
|
||||
computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
monthlyTotalCredits.value ?? 0
|
||||
)
|
||||
const cycleLabel = computed(() =>
|
||||
subscription.value?.duration === 'ANNUAL'
|
||||
? t('subscription.yearly')
|
||||
: t('subscription.monthly')
|
||||
)
|
||||
|
||||
const refillsDateShort = computed(() => {
|
||||
const raw = subscription.value?.renewalDate
|
||||
if (!raw) return ''
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime())
|
||||
? ''
|
||||
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
|
||||
})
|
||||
const cycleUsedPercent = computed(() =>
|
||||
Math.round(usage.value.usedFraction * 100)
|
||||
)
|
||||
|
||||
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
|
||||
|
||||
const refillsLabel = computed(() =>
|
||||
hasRefillsDate.value
|
||||
? t('subscription.refillsDate', { date: refillsDateShort.value })
|
||||
: t('subscription.refillsNextCycle')
|
||||
const cycleStatusLabel = computed(() =>
|
||||
t('subscription.percentUsed', { percent: cycleUsedPercent.value })
|
||||
)
|
||||
|
||||
const formatCreditCount = (value: number) =>
|
||||
@@ -270,82 +211,58 @@ const formatCreditCount = (value: number) =>
|
||||
numberOptions: { maximumFractionDigits: 0 }
|
||||
})
|
||||
|
||||
const monthlyTotalDisplay = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
const allowanceTotalDisplay = computed(() => {
|
||||
const total = allowanceTotalCredits.value
|
||||
return total === null ? '—' : formatCreditCount(total)
|
||||
})
|
||||
|
||||
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
|
||||
|
||||
const compactNumber = computed(
|
||||
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
|
||||
)
|
||||
const monthlyRemainingCompact = computed(() =>
|
||||
compactNumber.value.format(monthlyBonusCreditsValue.value)
|
||||
)
|
||||
const monthlyTotalCompact = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
return total === null ? '—' : compactNumber.value.format(total)
|
||||
})
|
||||
|
||||
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
|
||||
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
|
||||
const usedBarWidth = computed(
|
||||
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
|
||||
)
|
||||
const monthlyUsageLabel = computed(() =>
|
||||
t('subscription.monthlyUsageProgress', {
|
||||
const cycleUsageLabel = computed(() =>
|
||||
t('subscription.usageProgress', {
|
||||
used: usedDisplay.value,
|
||||
total: monthlyTotalDisplay.value
|
||||
total: allowanceTotalDisplay.value
|
||||
})
|
||||
)
|
||||
|
||||
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
|
||||
const showBreakdown = computed(
|
||||
() => (isActiveSubscription.value || frozen) && !zeroState
|
||||
)
|
||||
const showBar = computed(
|
||||
() =>
|
||||
showBreakdown.value &&
|
||||
monthlyTotalCredits.value !== null &&
|
||||
monthlyTotalCredits.value > 0
|
||||
allowanceTotalCredits.value !== null &&
|
||||
allowanceTotalCredits.value > 0
|
||||
)
|
||||
const showActionButton = computed(
|
||||
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
|
||||
() =>
|
||||
(isActiveSubscription.value || frozen) &&
|
||||
!zeroState &&
|
||||
permissions.value.canTopUp
|
||||
)
|
||||
|
||||
const isMonthlyDepleted = computed(
|
||||
const isAllowanceDepleted = computed(
|
||||
() =>
|
||||
!isPaused.value &&
|
||||
!frozen &&
|
||||
showBar.value &&
|
||||
!isLoadingBalance.value &&
|
||||
balance.value != null &&
|
||||
monthlyBonusCreditsValue.value <= 0
|
||||
)
|
||||
const isOutOfCredits = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
const isSpendingAdditional = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
|
||||
() => isAllowanceDepleted.value && prepaidCreditsValue.value > 0
|
||||
)
|
||||
// Fully out (monthly depleted and no additional credits left): emphasize the
|
||||
// add-credits button. Spending-additional keeps the quieter tertiary.
|
||||
const isOutOfCredits = computed(
|
||||
() => isAllowanceDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
|
||||
const emptyStateNotice = computed(() => {
|
||||
if (isOutOfCredits.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
|
||||
: t('subscription.outOfCreditsTitleNoDate'),
|
||||
description: t('subscription.outOfCreditsDescription')
|
||||
}
|
||||
}
|
||||
if (isMonthlyDepleted.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.monthlyCreditsUsedUpTitle', {
|
||||
date: refillsDateShort.value
|
||||
})
|
||||
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
|
||||
description: t('subscription.monthlyCreditsUsedUpDescription')
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
|
||||
await Promise.all([fetchBalance(), fetchStatus()])
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
|
||||
/**
|
||||
* Composable for handling subscription credit calculations and formatting.
|
||||
@@ -64,12 +70,44 @@ export function useSubscriptionCredits() {
|
||||
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
|
||||
)
|
||||
|
||||
// Total credits granted for the current billing cycle. Team plans read the
|
||||
// credit stop; personal tiers read the tier grant. Annual plans front-load the
|
||||
// whole year, so multiply the monthly nominal by the cycle length.
|
||||
const cycleMonths = computed(() =>
|
||||
toValue(billingContext.subscription)?.duration === 'ANNUAL' ? 12 : 1
|
||||
)
|
||||
const allowanceTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = toValue(billingContext.currentTeamCreditStop)
|
||||
const tier = toValue(billingContext.subscription)?.tier
|
||||
const tierKey = tier
|
||||
? (TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY)
|
||||
: DEFAULT_TIER_KEY
|
||||
const monthly = teamStop
|
||||
? teamStop.credits_monthly
|
||||
: getTierCredits(tierKey)
|
||||
return monthly === null ? null : monthly * cycleMonths.value
|
||||
})
|
||||
|
||||
// Usage of that allowance drives the credits bar. Paused plans read as unused
|
||||
// (credits are frozen), so force it to zero.
|
||||
const usage = computed(() => {
|
||||
const base = computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
allowanceTotalCredits.value ?? 0
|
||||
)
|
||||
return toValue(billingContext.isPaused)
|
||||
? { ...base, used: 0, usedFraction: 0 }
|
||||
: base
|
||||
})
|
||||
|
||||
return {
|
||||
totalCredits,
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
isLoadingBalance,
|
||||
allowanceTotalCredits,
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,5 @@ export const PRESERVED_QUERY_NAMESPACES = {
|
||||
SHARE_AUTH: 'share_auth',
|
||||
CREATE_WORKSPACE: 'create_workspace',
|
||||
OAUTH: 'oauth',
|
||||
PRICING: 'pricing',
|
||||
DESKTOP_LOGIN: 'desktop_login'
|
||||
PRICING: 'pricing'
|
||||
} as const
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
|
||||
<BaseModalLayout
|
||||
content-title=""
|
||||
data-testid="settings-dialog"
|
||||
size="full"
|
||||
header-height-class="h-22"
|
||||
:content-padding="isWorkspacePanel ? 'flush' : 'default'"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
@@ -48,6 +54,7 @@
|
||||
id="keybinding-panel-header"
|
||||
class="flex-1"
|
||||
/>
|
||||
<WorkspaceSettingsHeader v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -55,6 +62,7 @@
|
||||
v-if="activeCategoryKey === 'keybinding'"
|
||||
id="keybinding-panel-actions"
|
||||
/>
|
||||
<WorkspaceMenuButton v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
@@ -93,8 +101,11 @@ import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
|
||||
import WorkspaceSettingsHeader from '@/platform/workspace/components/dialogs/settings/WorkspaceSettingsHeader.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type {
|
||||
@@ -135,6 +146,14 @@ const { fetchBalance } = useBillingContext()
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
|
||||
// Let panels deep-link into a sibling panel (e.g. Overview → Members).
|
||||
const { requestedPanelKey } = useSettingsNavigation()
|
||||
watch(requestedPanelKey, (key) => {
|
||||
if (!key) return
|
||||
activeCategoryKey.value = key
|
||||
requestedPanelKey.value = null
|
||||
})
|
||||
|
||||
const searchableNavItems = computed(() =>
|
||||
navGroups.value.flatMap((g) =>
|
||||
g.items.map((item) => ({
|
||||
@@ -172,6 +191,17 @@ const activePanel = computed(() => {
|
||||
return findPanelByKey(activeCategoryKey.value)
|
||||
})
|
||||
|
||||
const WORKSPACE_PANEL_KEYS = [
|
||||
'workspace',
|
||||
'workspace-members',
|
||||
'workspace-partner-nodes'
|
||||
]
|
||||
const isWorkspacePanel = computed(
|
||||
() =>
|
||||
!!activeCategoryKey.value &&
|
||||
WORKSPACE_PANEL_KEYS.includes(activeCategoryKey.value)
|
||||
)
|
||||
|
||||
const getGroupSortOrder = (group: SettingTreeNode): number =>
|
||||
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
|
||||
|
||||
|
||||
@@ -233,5 +233,12 @@ describe('useSettingUI', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('splits the workspace panel into plan and members sidebar entries', () => {
|
||||
const { navGroups } = useSettingUI()
|
||||
const keys = navKeys(navGroups.value)
|
||||
expect(keys).toContain('workspace')
|
||||
expect(keys).toContain('workspace-members')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,13 +27,14 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
keybinding: 'icon-[lucide--keyboard]',
|
||||
LiteGraph: 'icon-[lucide--workflow]',
|
||||
'Mask Editor': 'icon-[lucide--pen-tool]',
|
||||
Members: 'icon-[lucide--users]',
|
||||
Other: 'icon-[lucide--ellipsis]',
|
||||
PlanCredits: 'icon-[lucide--credit-card]',
|
||||
PlanCredits: 'icon-[lucide--receipt-text]',
|
||||
secrets: 'icon-[lucide--key-round]',
|
||||
'server-config': 'icon-[lucide--server]',
|
||||
subscription: 'icon-[lucide--credit-card]',
|
||||
user: 'icon-[lucide--user]',
|
||||
workspace: 'icon-[lucide--building-2]'
|
||||
workspace: 'icon-[lucide--receipt-text]'
|
||||
}
|
||||
|
||||
interface SettingPanelItem {
|
||||
@@ -175,16 +176,30 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
// Workspace panel: only available on cloud with team workspaces enabled
|
||||
const workspacePanel: SettingPanelItem = {
|
||||
// Workspace panels: only available on cloud with team workspaces enabled.
|
||||
// The old single "Workspace" panel is split into three sidebar entries; the
|
||||
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
|
||||
const planCreditsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
label: 'PlanCredits',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
|
||||
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const membersPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-members',
|
||||
label: 'Members',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,7 +260,9 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel, membersPanel]
|
||||
: []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isDesktop ? [serverConfigPanel] : []),
|
||||
@@ -295,8 +312,13 @@ export function useSettingUI(
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
...(isLoggedIn.value &&
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel.node, membersPanel.node]
|
||||
: []),
|
||||
// The legacy per-account Credits panel is redundant once the workspace
|
||||
// Plan & Credits panel is present, which now owns the credit balance.
|
||||
...(!shouldShowWorkspacePanel.value &&
|
||||
isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
: [])
|
||||
|
||||
13
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
13
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// A one-shot request to switch the open Settings dialog to another panel, so a
|
||||
// panel's content can deep-link into a sibling panel (e.g. Overview → Members).
|
||||
const requestedPanelKey = ref<string | null>(null)
|
||||
|
||||
export function useSettingsNavigation() {
|
||||
function navigateToPanel(key: string) {
|
||||
requestedPanelKey.value = key
|
||||
}
|
||||
|
||||
return { requestedPanelKey, navigateToPanel }
|
||||
}
|
||||
@@ -87,3 +87,5 @@ export type SettingPanelType =
|
||||
| 'subscription'
|
||||
| 'user'
|
||||
| 'workspace'
|
||||
| 'workspace-members'
|
||||
| 'workspace-partner-nodes'
|
||||
|
||||
@@ -37,6 +37,11 @@ export interface Member {
|
||||
// billing lifecycle actions (cancel / reactivate / downgrade).
|
||||
// Optional: the cloud OpenAPI does not carry this field yet.
|
||||
is_original_owner?: boolean
|
||||
// Last time the member ran or interacted with the workspace, and the credits
|
||||
// they've consumed in the current billing cycle. Optional: the cloud OpenAPI
|
||||
// does not carry these fields yet.
|
||||
last_active_at?: string | null
|
||||
credits_used_this_month?: number
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
@@ -244,6 +249,7 @@ export type BillingSubscriptionStatus =
|
||||
| 'scheduled'
|
||||
| 'ended'
|
||||
| 'canceled'
|
||||
| 'paused'
|
||||
|
||||
export type BillingStatus =
|
||||
| 'awaiting_payment_method'
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<!-- Credits Section -->
|
||||
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Loading state while subscription is being set up -->
|
||||
<div
|
||||
v-if="isSettingUp"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Billing data still loading: avoid rendering a false Free/$0 plan -->
|
||||
<div
|
||||
v-else-if="isLoading && !subscription"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- Billing fetch failed: offer retry rather than a misleading Free plan -->
|
||||
<div
|
||||
v-else-if="error && !subscription"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke p-6"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-text-secondary">
|
||||
<i class="pi pi-exclamation-circle text-danger" />
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div class="rounded-2xl border border-interface-stroke/60 p-6">
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
|
||||
@@ -439,11 +439,13 @@ const subscriptionTierName = computed(() => {
|
||||
: baseName
|
||||
})
|
||||
|
||||
const planDisplayName = computed(() =>
|
||||
isInPersonalWorkspace.value
|
||||
? subscriptionTierName.value
|
||||
const planDisplayName = computed(() => {
|
||||
if (isInPersonalWorkspace.value) return subscriptionTierName.value
|
||||
// 'ENTERPRISE' is a wire tier not yet in the generated SubscriptionTier union.
|
||||
return (subscription.value?.tier as string | null) === 'ENTERPRISE'
|
||||
? t('subscription.enterprisePlanName')
|
||||
: t('subscription.teamPlanName')
|
||||
)
|
||||
})
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md text-base font-semibold text-white"
|
||||
:style="{
|
||||
background: gradient,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
}"
|
||||
:class="
|
||||
cn(
|
||||
'flex aspect-square size-8 items-center justify-center overflow-hidden rounded-md text-base font-semibold text-white',
|
||||
$attrs.class as string
|
||||
)
|
||||
"
|
||||
:style="imageUrl ? undefined : { background: gradient, textShadow }"
|
||||
>
|
||||
{{ letter }}
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="workspaceName"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<template v-else>{{ letter }}</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { workspaceName } = defineProps<{
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { workspaceName, imageUrl } = defineProps<{
|
||||
workspaceName: string
|
||||
imageUrl?: string
|
||||
}>()
|
||||
|
||||
const textShadow = '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
|
||||
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
|
||||
|
||||
const gradient = computed(() => {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('ChangeMemberRoleDialogContent', () => {
|
||||
mockChangeMemberRole.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('shows promote copy and confirms with Make owner', async () => {
|
||||
it('shows promote copy and confirms with Make admin', async () => {
|
||||
const { user } = renderDialog('owner')
|
||||
|
||||
expect(
|
||||
|
||||
@@ -64,6 +64,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
@@ -80,7 +81,11 @@ const workspaceName = ref('')
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
return (
|
||||
name.length >= 1 &&
|
||||
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
|
||||
safeNameRegex.test(name)
|
||||
)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -70,7 +71,11 @@ const newWorkspaceName = ref(workspaceStore.workspaceName)
|
||||
const isValidName = computed(() => {
|
||||
const name = newWorkspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
return (
|
||||
name.length >= 1 &&
|
||||
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
|
||||
safeNameRegex.test(name)
|
||||
)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
@@ -35,7 +35,7 @@
|
||||
:value="email"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
'rounded-full bg-tertiary-background-hover',
|
||||
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">{{ title }}</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="close"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 p-4">
|
||||
<Button variant="muted-textonly" @click="close">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="requestMore">
|
||||
{{ $t('workspacePanel.requestMore') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { dialogKey, onRequestMore } = defineProps<{
|
||||
dialogKey: string
|
||||
title: string
|
||||
message: string
|
||||
onRequestMore: () => void
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function close() {
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
|
||||
function requestMore() {
|
||||
onRequestMore()
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -232,7 +232,7 @@ describe('TeamWorkspacesDialogContent', () => {
|
||||
expect(findCreateButton(container)).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables create button for name exceeding 50 characters', async () => {
|
||||
it('disables create button for name exceeding the character limit', async () => {
|
||||
const { container, user } = mountComponent()
|
||||
const input = container.querySelector(
|
||||
'#workspace-name-input'
|
||||
|
||||
@@ -145,6 +145,7 @@ import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfil
|
||||
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
|
||||
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
@@ -178,7 +179,11 @@ const tierLabels = computed(
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
return name.length >= 1 && name.length <= 50 && SAFE_NAME_REGEX.test(name)
|
||||
return (
|
||||
name.length >= 1 &&
|
||||
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
|
||||
SAFE_NAME_REGEX.test(name)
|
||||
)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="banner"
|
||||
role="status"
|
||||
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4 shrink-0',
|
||||
// Muted circle for the calm plan-ending notice; amber triangle for
|
||||
// every action-needed problem (paused, payment failed, out of credits).
|
||||
banner.kind === 'ending'
|
||||
? 'icon-[lucide--circle-alert] text-muted-foreground'
|
||||
: 'icon-[lucide--triangle-alert] text-warning-background'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
|
||||
</div>
|
||||
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="banner.showAction"
|
||||
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
|
||||
>
|
||||
<slot name="actions" />
|
||||
<template v-if="banner.kind === 'outOfCredits'">
|
||||
<Button variant="textonly" size="lg" @click="dismiss">
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="handleAddCredits">
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-else-if="banner.kind === 'ending'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
|
||||
</Button>
|
||||
<Button v-else variant="inverted" size="lg">
|
||||
{{ $t('workspacePanel.billingStatus.updatePayment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const {
|
||||
billingStatus,
|
||||
isPaused,
|
||||
isActiveSubscription,
|
||||
subscription,
|
||||
renewalDate
|
||||
} = useBillingContext()
|
||||
const { permissions, isInPersonalWorkspace } = useWorkspaceUI()
|
||||
const dialogService = useDialogService()
|
||||
const { isResubscribing, handleResubscribe } = useResubscribe()
|
||||
|
||||
const canManage = computed(() => permissions.value.canManageSubscription)
|
||||
|
||||
const cycleResetDate = computed(() => {
|
||||
const raw = renewalDate.value
|
||||
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
|
||||
})
|
||||
|
||||
const planEndDate = computed(() => {
|
||||
const raw = subscription.value?.endDate
|
||||
return raw
|
||||
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: ''
|
||||
})
|
||||
|
||||
// Out of credits: an active, non-paused team that has exhausted its balance.
|
||||
// Paused takes over this slot (see priority below). Dismissible for the session.
|
||||
const dismissed = ref(false)
|
||||
const isOutOfCredits = computed(
|
||||
() =>
|
||||
isActiveSubscription.value &&
|
||||
!isPaused.value &&
|
||||
subscription.value?.hasFunds === false
|
||||
)
|
||||
|
||||
// A cancelled-but-still-active plan is winding down to its end date. Unlike the
|
||||
// states above it's a calm, owner-initiated notice (not a problem), so it sits
|
||||
// last and reads with the muted circle icon and a low-key secondary action.
|
||||
const isEnding = computed(
|
||||
() =>
|
||||
isActiveSubscription.value &&
|
||||
!isPaused.value &&
|
||||
(subscription.value?.isCancelled ?? false) &&
|
||||
planEndDate.value !== ''
|
||||
)
|
||||
|
||||
// One status banner slot across every workspace tab, in priority order: paused →
|
||||
// payment-failure warning → out of credits → plan ending. All owner/admin-only
|
||||
// (members can't act on any of them).
|
||||
const banner = computed(() => {
|
||||
if (isInPersonalWorkspace.value) return null
|
||||
|
||||
if (isPaused.value) {
|
||||
return {
|
||||
kind: 'paused' as const,
|
||||
title: t('workspacePanel.billingStatus.paused.title'),
|
||||
body: canManage.value
|
||||
? t('workspacePanel.billingStatus.paused.body')
|
||||
: t('workspacePanel.billingStatus.paused.memberBody'),
|
||||
showAction: canManage.value
|
||||
}
|
||||
}
|
||||
|
||||
if (billingStatus.value === 'payment_failed' && canManage.value) {
|
||||
return {
|
||||
kind: 'warning' as const,
|
||||
title: t('workspacePanel.billingStatus.warning.title'),
|
||||
body: t('workspacePanel.billingStatus.warning.body', {
|
||||
date: cycleResetDate.value
|
||||
}),
|
||||
showAction: true
|
||||
}
|
||||
}
|
||||
|
||||
if (isOutOfCredits.value && canManage.value && !dismissed.value) {
|
||||
return {
|
||||
kind: 'outOfCredits' as const,
|
||||
title: t('workspacePanel.billingStatus.outOfCredits.title'),
|
||||
body: cycleResetDate.value
|
||||
? t('workspacePanel.billingStatus.outOfCredits.body', {
|
||||
date: cycleResetDate.value
|
||||
})
|
||||
: t('workspacePanel.billingStatus.outOfCredits.bodyNoDate'),
|
||||
showAction: true
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnding.value && canManage.value) {
|
||||
return {
|
||||
kind: 'ending' as const,
|
||||
title: t('workspacePanel.billingStatus.ending.title', {
|
||||
date: planEndDate.value
|
||||
}),
|
||||
body: t('workspacePanel.billingStatus.ending.body'),
|
||||
showAction: true
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
function handleAddCredits() {
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
</script>
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:data-testid="`member-row-${member.id}`"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
isSingleSeatPlan ? 'grid-cols-1' : gridCols,
|
||||
striped && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
class="size-8"
|
||||
:photo-url="isCurrentUser ? photoUrl : undefined"
|
||||
:pt:icon:class="{ 'text-xl!': !isCurrentUser || !photoUrl }"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span v-if="isCurrentUser" class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="showRoleColumn && !isSingleSeatPlan"
|
||||
class="text-right text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
member.role === 'owner'
|
||||
? $t('workspaceSwitcher.roleOwner')
|
||||
: $t('workspaceSwitcher.roleMember')
|
||||
}}
|
||||
</span>
|
||||
<div
|
||||
v-if="canManageMembers && !isSingleSeatPlan"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="!isCurrentUser && !isOriginalOwner"
|
||||
:entries="menuItems"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
showRoleColumn = false,
|
||||
canManageMembers = false,
|
||||
isSingleSeatPlan = false,
|
||||
isOriginalOwner = false,
|
||||
striped = false,
|
||||
menuItems = []
|
||||
} = defineProps<{
|
||||
member: WorkspaceMember
|
||||
isCurrentUser: boolean
|
||||
photoUrl?: string
|
||||
gridCols: string
|
||||
showRoleColumn?: boolean
|
||||
canManageMembers?: boolean
|
||||
isSingleSeatPlan?: boolean
|
||||
isOriginalOwner?: boolean
|
||||
striped?: boolean
|
||||
menuItems?: MenuItem[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<TableRow
|
||||
:data-testid="`member-row-${member.id}`"
|
||||
class="group hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{
|
||||
backgroundColor: userBadgeColor(member.name || member.email)
|
||||
}"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ initial }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span v-if="isCurrentUser" class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ $t(roleLabelKey(member.role, isOriginalOwner)) }}
|
||||
</TableCell>
|
||||
<TableCell v-if="canManageMembers" class="text-sm text-muted-foreground">
|
||||
{{ lastActivityLabel }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="canManageMembers"
|
||||
class="text-right text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ creditsLabel }}
|
||||
</TableCell>
|
||||
<TableCell v-if="canManageMembers" class="text-right" @click.stop>
|
||||
<DropdownMenu
|
||||
v-if="showMenu"
|
||||
:entries="menuItems"
|
||||
:modal="false"
|
||||
content-class="min-w-44"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
import { roleLabelKey } from '@/platform/workspace/utils/roleLabels'
|
||||
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
|
||||
|
||||
const {
|
||||
member,
|
||||
isCurrentUser,
|
||||
canManageMembers = false,
|
||||
isOriginalOwner = false,
|
||||
menuItems = []
|
||||
} = defineProps<{
|
||||
member: WorkspaceMember
|
||||
isCurrentUser: boolean
|
||||
canManageMembers?: boolean
|
||||
isOriginalOwner?: boolean
|
||||
menuItems?: MenuItem[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const initial = computed(() =>
|
||||
(member.name || member.email).charAt(0).toUpperCase()
|
||||
)
|
||||
|
||||
// The creator and the current user can't be managed from their own row.
|
||||
const showMenu = computed(
|
||||
() => canManageMembers && !isCurrentUser && !isOriginalOwner
|
||||
)
|
||||
|
||||
const lastActivityLabel = computed(() => {
|
||||
if (!member.lastActivity) return '—'
|
||||
return formatRelativeTime(member.lastActivity, new Date(), {
|
||||
justNow: t('workspacePanel.members.activity.justNow'),
|
||||
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
|
||||
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
|
||||
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
|
||||
})
|
||||
})
|
||||
|
||||
const creditsLabel = computed(() =>
|
||||
(member.creditsUsedThisMonth ?? 0).toLocaleString()
|
||||
)
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user