mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 18:22:40 +00:00
feat(frontend): update cloud branch 2025-10-16 (#6096)
## Summary Updates with cloud specific features merged into `main`. Notable changes include the `DISTRIBUTION=cloud` changes. Will also be changing cloud build workflow to build with that flag in https://github.com/Comfy-Org/cloud/pull/1043 ## Changes -bb61d9822feat: AssetCard tweaks (#6085) -05f73523ffix terminal style (#6056) -d5fa22168Add distribution detection pattern (#6028) -6c36aaa1dfeat: Improve MediaAssetCard video controls and add gallery view (#6065) -6944ef0a2fix Cloudbadge (#6063) -6764f8dabBadge for cloud environment (#6048) --------- Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Co-authored-by: Jin Yi <jin12cc@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
@@ -85,6 +85,10 @@
|
|||||||
--color-bypass: #6a246a;
|
--color-bypass: #6a246a;
|
||||||
--color-error: #962a2a;
|
--color-error: #962a2a;
|
||||||
|
|
||||||
|
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
|
||||||
|
--text-xxxs: 0.5625rem;
|
||||||
|
--text-xxxs--line-height: calc(1 / 0.5625);
|
||||||
|
|
||||||
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
|
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
|
||||||
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
|
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
|
||||||
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
|
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ import type { CSSProperties, Component } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||||
@@ -265,7 +266,7 @@ const moreMenuItem = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => {
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
return [
|
const items: MenuItem[] = [
|
||||||
{
|
{
|
||||||
key: 'docs',
|
key: 'docs',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@@ -305,8 +306,12 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
void commandStore.execute('Comfy.ContactSupport')
|
void commandStore.execute('Comfy.ContactSupport')
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
|
|
||||||
|
// Extension manager - only in non-cloud distributions
|
||||||
|
if (!isCloud) {
|
||||||
|
items.push({
|
||||||
key: 'manager',
|
key: 'manager',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: PuzzleIcon,
|
icon: PuzzleIcon,
|
||||||
@@ -319,17 +324,20 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
})
|
})
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
{
|
}
|
||||||
key: 'more',
|
|
||||||
type: 'item',
|
items.push({
|
||||||
icon: '',
|
key: 'more',
|
||||||
label: t('helpCenter.more'),
|
type: 'item',
|
||||||
visible: hasVisibleMoreItems.value,
|
icon: '',
|
||||||
action: () => {}, // No action for more item
|
label: t('helpCenter.more'),
|
||||||
items: moreItems.value
|
visible: hasVisibleMoreItems.value,
|
||||||
}
|
action: () => {}, // No action for more item
|
||||||
]
|
items: moreItems.value
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
// Utility Functions
|
// Utility Functions
|
||||||
@@ -420,6 +428,9 @@ const formatReleaseDate = (dateString?: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
|
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
|
||||||
|
// Hide update buttons in cloud distribution
|
||||||
|
if (isCloud) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
releaseStore.shouldShowUpdateButton &&
|
releaseStore.shouldShowUpdateButton &&
|
||||||
release === releaseStore.recentReleases[0]
|
release === releaseStore.recentReleases[0]
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
|
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
|
||||||
class="z-1001 flex h-[38px] w-full content-end"
|
class="z-1001 flex h-9.5 w-full content-end"
|
||||||
style="background: var(--border-color)"
|
style="background: var(--border-color)"
|
||||||
>
|
>
|
||||||
<WorkflowTabs />
|
<WorkflowTabs />
|
||||||
|
<TopbarBadges />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-show="showTopMenu"
|
v-show="showTopMenu"
|
||||||
ref="topMenuRef"
|
ref="topMenuRef"
|
||||||
class="comfyui-menu flex items-center"
|
class="comfyui-menu flex items-center bg-gray-100"
|
||||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||||
>
|
>
|
||||||
<CommandMenubar />
|
<CommandMenubar />
|
||||||
@@ -44,9 +45,10 @@ import { app } from '@/scripts/app'
|
|||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
|
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
|
||||||
|
|
||||||
|
import TopbarBadges from './TopbarBadges.vue'
|
||||||
|
|
||||||
const workspaceState = useWorkspaceStore()
|
const workspaceState = useWorkspaceStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||||
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
|
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
|
||||||
const showTopMenu = computed(
|
const showTopMenu = computed(
|
||||||
|
|||||||
20
src/components/topbar/TopbarBadge.vue
Normal file
20
src/components/topbar/TopbarBadge.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2 bg-comfy-menu-secondary px-3">
|
||||||
|
<div
|
||||||
|
v-if="badge.label"
|
||||||
|
class="rounded-full bg-white px-1.5 py-0.5 text-xxxs font-semibold text-black"
|
||||||
|
>
|
||||||
|
{{ badge.label }}
|
||||||
|
</div>
|
||||||
|
<div class="font-inter text-sm font-extrabold text-slate-100">
|
||||||
|
{{ badge.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TopbarBadge } from '@/types/comfy'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
badge: TopbarBadge
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
17
src/components/topbar/TopbarBadges.vue
Normal file
17
src/components/topbar/TopbarBadges.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<TopbarBadge
|
||||||
|
v-for="badge in topbarBadgeStore.badges"
|
||||||
|
:key="badge.text"
|
||||||
|
:badge
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
|
||||||
|
|
||||||
|
import TopbarBadge from './TopbarBadge.vue'
|
||||||
|
|
||||||
|
const topbarBadgeStore = useTopbarBadgeStore()
|
||||||
|
</script>
|
||||||
@@ -5,12 +5,14 @@ import { debounce } from 'es-toolkit/compat'
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { markRaw, onMounted, onUnmounted } from 'vue'
|
import { markRaw, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
import { isDesktop } from '@/platform/distribution/types'
|
||||||
|
|
||||||
export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||||
const fitAddon = new FitAddon()
|
const fitAddon = new FitAddon()
|
||||||
const terminal = markRaw(
|
const terminal = markRaw(
|
||||||
new Terminal({
|
new Terminal({
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
theme: { background: '#171717' }
|
theme: isDesktop ? { background: '#171717' } : undefined
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
terminal.loadAddon(fitAddon)
|
terminal.loadAddon(fitAddon)
|
||||||
|
|||||||
16
src/extensions/core/cloudBadge.ts
Normal file
16
src/extensions/core/cloudBadge.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { useExtensionService } from '@/services/extensionService'
|
||||||
|
|
||||||
|
useExtensionService().registerExtension({
|
||||||
|
name: 'Comfy.CloudBadge',
|
||||||
|
// Only show badge when running in cloud environment
|
||||||
|
topbarBadges: isCloud
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t('g.beta'),
|
||||||
|
text: 'Comfy Cloud'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
|
||||||
import './clipspace'
|
import './clipspace'
|
||||||
import './contextMenuFilter'
|
import './contextMenuFilter'
|
||||||
import './dynamicPrompts'
|
import './dynamicPrompts'
|
||||||
@@ -21,3 +23,7 @@ import './uploadAudio'
|
|||||||
import './uploadImage'
|
import './uploadImage'
|
||||||
import './webcamCapture'
|
import './webcamCapture'
|
||||||
import './widgetInputs'
|
import './widgetInputs'
|
||||||
|
|
||||||
|
if (isCloud) {
|
||||||
|
import('./cloudBadge')
|
||||||
|
}
|
||||||
|
|||||||
@@ -197,7 +197,8 @@
|
|||||||
"volume": "Volume",
|
"volume": "Volume",
|
||||||
"halfSpeed": "0.5x",
|
"halfSpeed": "0.5x",
|
||||||
"1x": "1x",
|
"1x": "1x",
|
||||||
"2x": "2x"
|
"2x": "2x",
|
||||||
|
"beta": "BETA"
|
||||||
},
|
},
|
||||||
"manager": {
|
"manager": {
|
||||||
"title": "Custom Nodes Manager",
|
"title": "Custom Nodes Manager",
|
||||||
|
|||||||
@@ -84,6 +84,51 @@ export const NonInteractive: Story = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const WithPreviewImage: Story = {
|
||||||
|
args: {
|
||||||
|
asset: createAssetData({
|
||||||
|
preview_url: '/assets/images/comfy-logo-single.svg'
|
||||||
|
}),
|
||||||
|
interactive: true
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
() => ({
|
||||||
|
template:
|
||||||
|
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story: 'AssetCard with a preview image displayed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FallbackGradient: Story = {
|
||||||
|
args: {
|
||||||
|
asset: createAssetData({
|
||||||
|
preview_url: undefined
|
||||||
|
}),
|
||||||
|
interactive: true
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
() => ({
|
||||||
|
template:
|
||||||
|
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'AssetCard showing fallback gradient when no preview image is available.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const EdgeCases: Story = {
|
export const EdgeCases: Story = {
|
||||||
render: () => ({
|
render: () => ({
|
||||||
components: { AssetCard },
|
components: { AssetCard },
|
||||||
|
|||||||
@@ -4,38 +4,29 @@
|
|||||||
data-component-id="AssetCard"
|
data-component-id="AssetCard"
|
||||||
:data-asset-id="asset.id"
|
:data-asset-id="asset.id"
|
||||||
v-bind="elementProps"
|
v-bind="elementProps"
|
||||||
:class="
|
:class="cardClasses"
|
||||||
cn(
|
|
||||||
// Base layout and container styles (always applied)
|
|
||||||
'rounded-xl overflow-hidden transition-all duration-200',
|
|
||||||
interactive && 'group',
|
|
||||||
// Button-specific styles
|
|
||||||
interactive && [
|
|
||||||
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
|
|
||||||
'bg-gray-100 dark-theme:bg-charcoal-800',
|
|
||||||
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
|
|
||||||
'border-none',
|
|
||||||
'focus:outline-solid outline-blue-100 outline-4'
|
|
||||||
],
|
|
||||||
// Div-specific styles
|
|
||||||
!interactive && 'bg-gray-100 dark-theme:bg-charcoal-800'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
@click="interactive && $emit('select', asset)"
|
@click="interactive && $emit('select', asset)"
|
||||||
@keydown.enter="interactive && $emit('select', asset)"
|
@keydown.enter="interactive && $emit('select', asset)"
|
||||||
>
|
>
|
||||||
<div class="relative aspect-square w-full overflow-hidden">
|
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
|
||||||
|
<img
|
||||||
|
v-if="shouldShowImage"
|
||||||
|
:src="asset.preview_url"
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600"
|
v-else
|
||||||
|
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-400 via-gray-800 to-charcoal-400"
|
||||||
></div>
|
></div>
|
||||||
<AssetBadgeGroup :badges="asset.badges" />
|
<AssetBadgeGroup :badges="asset.badges" />
|
||||||
</div>
|
</div>
|
||||||
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
|
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
|
:id="titleId"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
|
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
|
||||||
'text-slate-800',
|
'text-slate-800',
|
||||||
'dark-theme:text-white'
|
'dark-theme:text-white'
|
||||||
)
|
)
|
||||||
@@ -44,6 +35,7 @@
|
|||||||
{{ asset.name }}
|
{{ asset.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
|
:id="descId"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
||||||
@@ -83,7 +75,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { useImage } from '@vueuse/core'
|
||||||
|
import { computed, useId } from 'vue'
|
||||||
|
|
||||||
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
@@ -94,13 +87,51 @@ const props = defineProps<{
|
|||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const titleId = useId()
|
||||||
|
const descId = useId()
|
||||||
|
|
||||||
|
const { error } = useImage({
|
||||||
|
src: props.asset.preview_url ?? '',
|
||||||
|
alt: props.asset.name
|
||||||
|
})
|
||||||
|
|
||||||
|
const shouldShowImage = computed(() => props.asset.preview_url && !error.value)
|
||||||
|
|
||||||
|
const cardClasses = computed(() => {
|
||||||
|
const base = [
|
||||||
|
'rounded-xl',
|
||||||
|
'overflow-hidden',
|
||||||
|
'transition-all',
|
||||||
|
'duration-200'
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!props.interactive) {
|
||||||
|
return cn(...base, 'bg-gray-100 dark-theme:bg-charcoal-800')
|
||||||
|
}
|
||||||
|
|
||||||
|
return cn(
|
||||||
|
...base,
|
||||||
|
'group',
|
||||||
|
'appearance-none bg-transparent p-0 m-0',
|
||||||
|
'font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||||
|
'bg-gray-100 dark-theme:bg-charcoal-800',
|
||||||
|
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
|
||||||
|
'border-none',
|
||||||
|
'focus:outline-solid outline-blue-100 outline-4'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const elementProps = computed(() =>
|
const elementProps = computed(() =>
|
||||||
props.interactive
|
props.interactive
|
||||||
? {
|
? {
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'aria-label': `Select asset ${props.asset.name}`
|
'aria-labelledby': titleId,
|
||||||
|
'aria-describedby': descId
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
'aria-labelledby': titleId,
|
||||||
|
'aria-describedby': descId
|
||||||
}
|
}
|
||||||
: {}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||||
|
|
||||||
|
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||||
import MediaAssetCard from './MediaAssetCard.vue'
|
import MediaAssetCard from './MediaAssetCard.vue'
|
||||||
|
|
||||||
const meta: Meta<typeof MediaAssetCard> = {
|
const meta: Meta<typeof MediaAssetCard> = {
|
||||||
title: 'AssetLibrary/MediaAssetCard',
|
title: 'Platform/Assets/MediaAssetCard',
|
||||||
component: MediaAssetCard,
|
component: MediaAssetCard,
|
||||||
|
decorators: [
|
||||||
|
() => ({
|
||||||
|
components: { ResultGallery },
|
||||||
|
setup() {
|
||||||
|
const galleryStore = useMediaAssetGalleryStore()
|
||||||
|
return { galleryStore }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<story />
|
||||||
|
<ResultGallery
|
||||||
|
v-model:active-index="galleryStore.activeIndex"
|
||||||
|
:all-gallery-items="galleryStore.items"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
context: {
|
context: {
|
||||||
control: 'select',
|
control: 'select',
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
:is="getTopComponent(asset.kind)"
|
:is="getTopComponent(asset.kind)"
|
||||||
:asset="asset"
|
:asset="asset"
|
||||||
:context="context"
|
:context="context"
|
||||||
@view="actions.viewAsset(asset!.id)"
|
@view="handleZoomClick"
|
||||||
@download="actions.downloadAsset(asset!.id)"
|
@download="actions.downloadAsset(asset!.id)"
|
||||||
@play="actions.playAsset(asset!.id)"
|
@play="actions.playAsset(asset!.id)"
|
||||||
@video-playing-state-changed="isVideoPlaying = $event"
|
@video-playing-state-changed="isVideoPlaying = $event"
|
||||||
@@ -41,31 +41,48 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Actions overlay (top-left) - show on hover or when menu is open, but not when video is playing -->
|
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
|
||||||
<template v-if="showActionsOverlay" #top-left>
|
<template v-if="showActionsOverlay" #top-left>
|
||||||
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
|
<MediaAssetActions
|
||||||
|
@menu-state-changed="isMenuOpen = $event"
|
||||||
|
@mouseenter="handleOverlayMouseEnter"
|
||||||
|
@mouseleave="handleOverlayMouseLeave"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Zoom button (top-right) - show on hover, but not when video is playing -->
|
<!-- Zoom button (top-right) - show on hover for all media types -->
|
||||||
<template v-if="showZoomOverlay" #top-right>
|
<template v-if="showZoomOverlay" #top-right>
|
||||||
<IconButton size="sm" @click="actions.viewAsset(asset!.id)">
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
@click.stop="handleZoomClick"
|
||||||
|
@mouseenter="handleOverlayMouseEnter"
|
||||||
|
@mouseleave="handleOverlayMouseLeave"
|
||||||
|
>
|
||||||
<i class="icon-[lucide--zoom-in] size-4" />
|
<i class="icon-[lucide--zoom-in] size-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Duration/Format chips (bottom-left) - hide when video is playing -->
|
<!-- Duration/Format chips (bottom-left) - show on hover even when playing -->
|
||||||
<template v-if="showDurationChips" #bottom-left>
|
<template v-if="showDurationChips" #bottom-left>
|
||||||
<SquareChip variant="light" :label="formattedDuration" />
|
<div
|
||||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
class="flex flex-wrap items-center gap-1"
|
||||||
|
@mouseenter="handleOverlayMouseEnter"
|
||||||
|
@mouseleave="handleOverlayMouseLeave"
|
||||||
|
>
|
||||||
|
<SquareChip variant="light" :label="formattedDuration" />
|
||||||
|
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Output count (bottom-right) - hide when video is playing -->
|
<!-- Output count (bottom-right) - show on hover even when playing -->
|
||||||
<template v-if="showOutputCount" #bottom-right>
|
<template v-if="showOutputCount" #bottom-right>
|
||||||
<IconTextButton
|
<IconTextButton
|
||||||
type="secondary"
|
type="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
:label="context?.outputCount?.toString() ?? '0'"
|
:label="context?.outputCount?.toString() ?? '0'"
|
||||||
@click="actions.openMoreOutputs(asset?.id || '')"
|
@click.stop="actions.openMoreOutputs(asset?.id || '')"
|
||||||
|
@mouseenter="handleOverlayMouseEnter"
|
||||||
|
@mouseleave="handleOverlayMouseLeave"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<i class="icon-[lucide--layers] size-4" />
|
<i class="icon-[lucide--layers] size-4" />
|
||||||
@@ -116,6 +133,7 @@ import { formatDuration } from '@/utils/formatUtil'
|
|||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||||
|
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||||
import type {
|
import type {
|
||||||
AssetContext,
|
AssetContext,
|
||||||
AssetMeta,
|
AssetMeta,
|
||||||
@@ -159,10 +177,12 @@ const cardContainerRef = ref<HTMLElement>()
|
|||||||
const isVideoPlaying = ref(false)
|
const isVideoPlaying = ref(false)
|
||||||
const isMenuOpen = ref(false)
|
const isMenuOpen = ref(false)
|
||||||
const showVideoControls = ref(false)
|
const showVideoControls = ref(false)
|
||||||
|
const isOverlayHovered = ref(false)
|
||||||
|
|
||||||
const isHovered = useElementHover(cardContainerRef)
|
const isHovered = useElementHover(cardContainerRef)
|
||||||
|
|
||||||
const actions = useMediaAssetActions()
|
const actions = useMediaAssetActions()
|
||||||
|
const galleryStore = useMediaAssetGalleryStore()
|
||||||
|
|
||||||
provide(MediaAssetKey, {
|
provide(MediaAssetKey, {
|
||||||
asset: toRef(() => asset),
|
asset: toRef(() => asset),
|
||||||
@@ -171,14 +191,14 @@ provide(MediaAssetKey, {
|
|||||||
showVideoControls
|
showVideoControls
|
||||||
})
|
})
|
||||||
|
|
||||||
const containerClasses = computed(() => {
|
const containerClasses = computed(() =>
|
||||||
return cn(
|
cn(
|
||||||
'gap-1',
|
'gap-1',
|
||||||
selected
|
selected
|
||||||
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
|
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
|
||||||
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
|
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
|
||||||
)
|
)
|
||||||
})
|
)
|
||||||
|
|
||||||
const formattedDuration = computed(() => {
|
const formattedDuration = computed(() => {
|
||||||
if (!asset?.duration) return ''
|
if (!asset?.duration) return ''
|
||||||
@@ -201,33 +221,58 @@ const durationChipClasses = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const showHoverActions = computed(() => {
|
const isCardOrOverlayHovered = computed(
|
||||||
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
|
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
|
||||||
})
|
)
|
||||||
|
|
||||||
const showZoomButton = computed(() => {
|
const showHoverActions = computed(
|
||||||
return asset?.kind === 'image' || asset?.kind === '3D'
|
() => !loading && !!asset && isCardOrOverlayHovered.value
|
||||||
})
|
)
|
||||||
|
|
||||||
const showActionsOverlay = computed(() => {
|
const showActionsOverlay = computed(
|
||||||
return showHoverActions.value && !isVideoPlaying.value
|
() =>
|
||||||
})
|
showHoverActions.value &&
|
||||||
|
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||||
|
)
|
||||||
|
|
||||||
const showZoomOverlay = computed(() => {
|
const showZoomOverlay = computed(
|
||||||
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
|
() =>
|
||||||
})
|
showHoverActions.value &&
|
||||||
|
asset?.kind !== '3D' &&
|
||||||
|
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||||
|
)
|
||||||
|
|
||||||
const showDurationChips = computed(() => {
|
const showDurationChips = computed(
|
||||||
return !loading && asset?.duration && !isVideoPlaying.value
|
() =>
|
||||||
})
|
!loading &&
|
||||||
|
asset?.duration &&
|
||||||
|
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||||
|
)
|
||||||
|
|
||||||
const showOutputCount = computed(() => {
|
const showOutputCount = computed(
|
||||||
return !loading && context?.outputCount && !isVideoPlaying.value
|
() =>
|
||||||
})
|
!loading &&
|
||||||
|
context?.outputCount &&
|
||||||
|
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||||
|
)
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (asset) {
|
if (asset) {
|
||||||
actions.selectAsset(asset)
|
actions.selectAsset(asset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOverlayMouseEnter = () => {
|
||||||
|
isOverlayHovered.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOverlayMouseLeave = () => {
|
||||||
|
isOverlayHovered.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleZoomClick = () => {
|
||||||
|
if (asset) {
|
||||||
|
galleryStore.openSingle(asset)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<IconTextButton
|
<IconTextButton
|
||||||
|
v-if="asset?.kind !== '3D'"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
class="dark-theme:text-white"
|
class="dark-theme:text-white"
|
||||||
label="Inspect asset"
|
label="Inspect asset"
|
||||||
@@ -93,6 +94,7 @@ import { computed, inject } from 'vue'
|
|||||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
|
|
||||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||||
|
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||||
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
||||||
|
|
||||||
@@ -102,14 +104,13 @@ const { close } = defineProps<{
|
|||||||
|
|
||||||
const { asset, context } = inject(MediaAssetKey)!
|
const { asset, context } = inject(MediaAssetKey)!
|
||||||
const actions = useMediaAssetActions()
|
const actions = useMediaAssetActions()
|
||||||
|
const galleryStore = useMediaAssetGalleryStore()
|
||||||
|
|
||||||
const showWorkflowOptions = computed(() => {
|
const showWorkflowOptions = computed(() => context.value.type)
|
||||||
return context.value.type
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleInspect = () => {
|
const handleInspect = () => {
|
||||||
if (asset.value) {
|
if (asset.value) {
|
||||||
actions.viewAsset(asset.value.id)
|
galleryStore.openSingle(asset.value)
|
||||||
}
|
}
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative h-full w-full overflow-hidden rounded bg-black"
|
class="relative h-full w-full overflow-hidden rounded bg-black"
|
||||||
@mouseenter="showControls = true"
|
@mouseenter="isHovered = true"
|
||||||
@mouseleave="showControls = false"
|
@mouseleave="isHovered = false"
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
:controls="showControls"
|
:controls="shouldShowControls"
|
||||||
preload="none"
|
preload="none"
|
||||||
:poster="asset.preview_url"
|
:poster="asset.preview_url"
|
||||||
class="relative h-full w-full object-contain"
|
class="relative h-full w-full object-contain"
|
||||||
@click.stop
|
@click.stop
|
||||||
@play="onVideoPlay"
|
@play="onVideoPlay"
|
||||||
@pause="onVideoPause"
|
@pause="onVideoPause"
|
||||||
|
@ended="onVideoEnded"
|
||||||
>
|
>
|
||||||
<source :src="asset.src || ''" />
|
<source :src="asset.src || ''" />
|
||||||
</video>
|
</video>
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||||
|
|
||||||
@@ -36,22 +37,32 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const videoRef = ref<HTMLVideoElement>()
|
const videoRef = ref<HTMLVideoElement>()
|
||||||
const showControls = ref(true)
|
const isHovered = ref(false)
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
|
||||||
watch(showControls, (controlsVisible) => {
|
// Always show controls when not playing, hide/show based on hover when playing
|
||||||
|
const shouldShowControls = computed(() => !isPlaying.value || isHovered.value)
|
||||||
|
|
||||||
|
watch(shouldShowControls, (controlsVisible) => {
|
||||||
emit('videoControlsChanged', controlsVisible)
|
emit('videoControlsChanged', controlsVisible)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
emit('videoControlsChanged', showControls.value)
|
emit('videoControlsChanged', shouldShowControls.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onVideoPlay = () => {
|
const onVideoPlay = () => {
|
||||||
showControls.value = true
|
isPlaying.value = true
|
||||||
emit('videoPlayingStateChanged', true)
|
emit('videoPlayingStateChanged', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVideoPause = () => {
|
const onVideoPause = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
emit('videoPlayingStateChanged', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVideoEnded = () => {
|
||||||
|
isPlaying.value = false
|
||||||
emit('videoPlayingStateChanged', false)
|
emit('videoPlayingStateChanged', false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ export function useMediaAssetActions() {
|
|||||||
console.log('Asset selected:', asset)
|
console.log('Asset selected:', asset)
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewAsset = (assetId: string) => {
|
|
||||||
console.log('Viewing asset:', assetId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadAsset = (assetId: string) => {
|
const downloadAsset = (assetId: string) => {
|
||||||
console.log('Downloading asset:', assetId)
|
console.log('Downloading asset:', assetId)
|
||||||
}
|
}
|
||||||
@@ -48,7 +44,6 @@ export function useMediaAssetActions() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
selectAsset,
|
selectAsset,
|
||||||
viewAsset,
|
|
||||||
downloadAsset,
|
downloadAsset,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
playAsset,
|
playAsset,
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||||
|
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
|
||||||
|
|
||||||
|
vi.mock('@/stores/queueStore', () => ({
|
||||||
|
ResultItemImpl: vi.fn().mockImplementation((data) => ({
|
||||||
|
...data,
|
||||||
|
url: ''
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useMediaAssetGalleryStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('openSingle', () => {
|
||||||
|
it('should convert AssetMeta to ResultItemImpl format', () => {
|
||||||
|
const store = useMediaAssetGalleryStore()
|
||||||
|
const mockAsset: AssetMeta = {
|
||||||
|
id: 'test-1',
|
||||||
|
name: 'test-image.png',
|
||||||
|
kind: 'image',
|
||||||
|
src: 'https://example.com/image.png',
|
||||||
|
size: 1024,
|
||||||
|
tags: [],
|
||||||
|
created_at: '2025-01-01'
|
||||||
|
}
|
||||||
|
|
||||||
|
store.openSingle(mockAsset)
|
||||||
|
|
||||||
|
expect(ResultItemImpl).toHaveBeenCalledWith({
|
||||||
|
filename: 'test-image.png',
|
||||||
|
subfolder: '',
|
||||||
|
type: 'output',
|
||||||
|
nodeId: '0',
|
||||||
|
mediaType: 'images'
|
||||||
|
})
|
||||||
|
expect(store.items).toHaveLength(1)
|
||||||
|
expect(store.activeIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set correct mediaType for video assets', () => {
|
||||||
|
const store = useMediaAssetGalleryStore()
|
||||||
|
const mockVideoAsset: AssetMeta = {
|
||||||
|
id: 'test-2',
|
||||||
|
name: 'test-video.mp4',
|
||||||
|
kind: 'video',
|
||||||
|
src: 'https://example.com/video.mp4',
|
||||||
|
size: 2048,
|
||||||
|
tags: [],
|
||||||
|
created_at: '2025-01-01'
|
||||||
|
}
|
||||||
|
|
||||||
|
store.openSingle(mockVideoAsset)
|
||||||
|
|
||||||
|
expect(ResultItemImpl).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
filename: 'test-video.mp4',
|
||||||
|
mediaType: 'video'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set correct mediaType for audio assets', () => {
|
||||||
|
const store = useMediaAssetGalleryStore()
|
||||||
|
const mockAudioAsset: AssetMeta = {
|
||||||
|
id: 'test-3',
|
||||||
|
name: 'test-audio.mp3',
|
||||||
|
kind: 'audio',
|
||||||
|
src: 'https://example.com/audio.mp3',
|
||||||
|
size: 512,
|
||||||
|
tags: [],
|
||||||
|
created_at: '2025-01-01'
|
||||||
|
}
|
||||||
|
|
||||||
|
store.openSingle(mockAudioAsset)
|
||||||
|
|
||||||
|
expect(ResultItemImpl).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
filename: 'test-audio.mp3',
|
||||||
|
mediaType: 'audio'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should override url getter with asset.src', () => {
|
||||||
|
const store = useMediaAssetGalleryStore()
|
||||||
|
const mockAsset: AssetMeta = {
|
||||||
|
id: 'test-4',
|
||||||
|
name: 'test.png',
|
||||||
|
kind: 'image',
|
||||||
|
src: 'https://example.com/custom-url.png',
|
||||||
|
size: 1024,
|
||||||
|
tags: [],
|
||||||
|
created_at: '2025-01-01'
|
||||||
|
}
|
||||||
|
|
||||||
|
store.openSingle(mockAsset)
|
||||||
|
|
||||||
|
const resultItem = store.items[0]
|
||||||
|
expect(resultItem.url).toBe('https://example.com/custom-url.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle assets without src gracefully', () => {
|
||||||
|
const store = useMediaAssetGalleryStore()
|
||||||
|
const mockAsset: AssetMeta = {
|
||||||
|
id: 'test-5',
|
||||||
|
name: 'no-src.png',
|
||||||
|
kind: 'image',
|
||||||
|
src: '',
|
||||||
|
size: 1024,
|
||||||
|
tags: [],
|
||||||
|
created_at: '2025-01-01'
|
||||||
|
}
|
||||||
|
|
||||||
|
store.openSingle(mockAsset)
|
||||||
|
|
||||||
|
const resultItem = store.items[0]
|
||||||
|
expect(resultItem.url).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update activeIndex and items when called multiple times', () => {
|
||||||
|
const store = useMediaAssetGalleryStore()
|
||||||
|
const asset1: AssetMeta = {
|
||||||
|
id: '1',
|
||||||
|
name: 'first.png',
|
||||||
|
kind: 'image',
|
||||||
|
src: 'url1',
|
||||||
|
size: 100,
|
||||||
|
tags: [],
|
||||||
|
created_at: '2025-01-01'
|
||||||
|
}
|
||||||
|
const asset2: AssetMeta = {
|
||||||
|
id: '2',
|
||||||
|
name: 'second.png',
|
||||||
|
kind: 'image',
|
||||||
|
src: 'url2',
|
||||||
|
size: 200,
|
||||||
|
tags: [],
|
||||||
|
created_at: '2025-01-01'
|
||||||
|
}
|
||||||
|
|
||||||
|
store.openSingle(asset1)
|
||||||
|
expect(store.items).toHaveLength(1)
|
||||||
|
expect(store.items[0].filename).toBe('first.png')
|
||||||
|
|
||||||
|
store.openSingle(asset2)
|
||||||
|
expect(store.items).toHaveLength(1)
|
||||||
|
expect(store.items[0].filename).toBe('second.png')
|
||||||
|
expect(store.activeIndex).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('close', () => {
|
||||||
|
it('should reset activeIndex to -1', () => {
|
||||||
|
const store = useMediaAssetGalleryStore()
|
||||||
|
const mockAsset: AssetMeta = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test.png',
|
||||||
|
kind: 'image',
|
||||||
|
src: 'test-url',
|
||||||
|
size: 1024,
|
||||||
|
tags: [],
|
||||||
|
created_at: '2025-01-01'
|
||||||
|
}
|
||||||
|
|
||||||
|
store.openSingle(mockAsset)
|
||||||
|
expect(store.activeIndex).toBe(0)
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
expect(store.activeIndex).toBe(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
47
src/platform/assets/composables/useMediaAssetGalleryStore.ts
Normal file
47
src/platform/assets/composables/useMediaAssetGalleryStore.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||||
|
|
||||||
|
export const useMediaAssetGalleryStore = defineStore(
|
||||||
|
'mediaAssetGallery',
|
||||||
|
() => {
|
||||||
|
const activeIndex = ref(-1)
|
||||||
|
const items = shallowRef<ResultItemImpl[]>([])
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
activeIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSingle = (asset: AssetMeta) => {
|
||||||
|
// Convert AssetMeta to ResultItemImpl format
|
||||||
|
const resultItem = new ResultItemImpl({
|
||||||
|
filename: asset.name,
|
||||||
|
subfolder: '',
|
||||||
|
type: 'output',
|
||||||
|
nodeId: '0',
|
||||||
|
mediaType: asset.kind === 'image' ? 'images' : asset.kind
|
||||||
|
})
|
||||||
|
|
||||||
|
// Override the url getter to use asset.src
|
||||||
|
Object.defineProperty(resultItem, 'url', {
|
||||||
|
get() {
|
||||||
|
return asset.src || ''
|
||||||
|
},
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = [resultItem]
|
||||||
|
activeIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeIndex,
|
||||||
|
items,
|
||||||
|
close,
|
||||||
|
openSingle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -12,7 +12,7 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
|||||||
|
|
||||||
const ASSETS_ENDPOINT = '/assets'
|
const ASSETS_ENDPOINT = '/assets'
|
||||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||||
const DEFAULT_LIMIT = 300
|
const DEFAULT_LIMIT = 500
|
||||||
|
|
||||||
export const MODELS_TAG = 'models'
|
export const MODELS_TAG = 'models'
|
||||||
export const MISSING_TAG = 'missing'
|
export const MISSING_TAG = 'missing'
|
||||||
|
|||||||
20
src/platform/distribution/types.ts
Normal file
20
src/platform/distribution/types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { isElectron } from '@/utils/envUtil'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribution types and compile-time constants for managing
|
||||||
|
* multi-distribution builds (Desktop, Localhost, Cloud)
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Distribution = 'desktop' | 'localhost' | 'cloud'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const __DISTRIBUTION__: Distribution
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current distribution - replaced at compile time */
|
||||||
|
const DISTRIBUTION: Distribution = __DISTRIBUTION__
|
||||||
|
|
||||||
|
/** Distribution type checks */
|
||||||
|
export const isDesktop = DISTRIBUTION === 'desktop' || isElectron() // TODO: replace with build var
|
||||||
|
export const isCloud = DISTRIBUTION === 'cloud'
|
||||||
|
// export const isLocalhost = !isDesktop && !isCloud
|
||||||
18
src/stores/topbarBadgeStore.ts
Normal file
18
src/stores/topbarBadgeStore.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { TopbarBadge } from '@/types/comfy'
|
||||||
|
|
||||||
|
import { useExtensionStore } from './extensionStore'
|
||||||
|
|
||||||
|
export const useTopbarBadgeStore = defineStore('topbarBadge', () => {
|
||||||
|
const extensionStore = useExtensionStore()
|
||||||
|
|
||||||
|
const badges = computed<TopbarBadge[]>(() =>
|
||||||
|
extensionStore.extensions.flatMap((e) => e.topbarBadges ?? [])
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
badges
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -33,6 +33,14 @@ type MenuCommandGroup = {
|
|||||||
commands: string[]
|
commands: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopbarBadge {
|
||||||
|
text: string
|
||||||
|
/**
|
||||||
|
* Optional badge label (e.g., "BETA", "ALPHA", "NEW")
|
||||||
|
*/
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type MissingNodeType =
|
export type MissingNodeType =
|
||||||
| string
|
| string
|
||||||
// Primarily used by group nodes.
|
// Primarily used by group nodes.
|
||||||
@@ -74,6 +82,10 @@ export interface ComfyExtension {
|
|||||||
* Badges to add to the about page
|
* Badges to add to the about page
|
||||||
*/
|
*/
|
||||||
aboutPageBadges?: AboutPageBadge[]
|
aboutPageBadges?: AboutPageBadge[]
|
||||||
|
/**
|
||||||
|
* Badges to add to the top bar
|
||||||
|
*/
|
||||||
|
topbarBadges?: TopbarBadge[]
|
||||||
/**
|
/**
|
||||||
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
|
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
|
||||||
* @param app The ComfyUI app instance
|
* @param app The ComfyUI app instance
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ describe('assetService', () => {
|
|||||||
const result = await assetService.getAssetModelFolders()
|
const result = await assetService.getAssetModelFolders()
|
||||||
|
|
||||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||||
'/assets?include_tags=models&limit=300'
|
'/assets?include_tags=models&limit=500'
|
||||||
)
|
)
|
||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2)
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ describe('assetService', () => {
|
|||||||
const result = await assetService.getAssetModels('checkpoints')
|
const result = await assetService.getAssetModels('checkpoints')
|
||||||
|
|
||||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||||
'/assets?include_tags=models,checkpoints&limit=300'
|
'/assets?include_tags=models,checkpoints&limit=500'
|
||||||
)
|
)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
|
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
|
||||||
@@ -236,7 +236,7 @@ describe('assetService', () => {
|
|||||||
|
|
||||||
// Verify API call includes correct category
|
// Verify API call includes correct category
|
||||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||||
'/assets?include_tags=models,checkpoints&limit=300'
|
'/assets?include_tags=models,checkpoints&limit=500'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ describe('assetService', () => {
|
|||||||
const result = await assetService.getAssetsByTag('models')
|
const result = await assetService.getAssetsByTag('models')
|
||||||
|
|
||||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||||
'/assets?include_tags=models&limit=300'
|
'/assets?include_tags=models&limit=500'
|
||||||
)
|
)
|
||||||
expect(result).toEqual(testAssets)
|
expect(result).toEqual(testAssets)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"src/types/**/*.d.ts",
|
"src/types/**/*.d.ts",
|
||||||
"tailwind.config.ts",
|
"tailwind.config.ts",
|
||||||
"tests-ui/**/*",
|
"tests-ui/**/*",
|
||||||
|
"vite.config.mts",
|
||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
// "vitest.setup.ts",
|
// "vitest.setup.ts",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ const addAuthHeaders = (proxy: any) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DISTRIBUTION = (process.env.DISTRIBUTION || 'localhost') as
|
||||||
|
| 'desktop'
|
||||||
|
| 'localhost'
|
||||||
|
| 'cloud'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '',
|
base: '',
|
||||||
server: {
|
server: {
|
||||||
@@ -257,7 +262,8 @@ export default defineConfig({
|
|||||||
__SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || ''),
|
__SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || ''),
|
||||||
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
|
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
|
||||||
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
|
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
|
||||||
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true'
|
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
|
||||||
|
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION)
|
||||||
},
|
},
|
||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ globalThis.__ALGOLIA_APP_ID__ = ''
|
|||||||
globalThis.__ALGOLIA_API_KEY__ = ''
|
globalThis.__ALGOLIA_API_KEY__ = ''
|
||||||
// @ts-expect-error - Global variables are defined in global.d.ts
|
// @ts-expect-error - Global variables are defined in global.d.ts
|
||||||
globalThis.__USE_PROD_CONFIG__ = false
|
globalThis.__USE_PROD_CONFIG__ = false
|
||||||
|
globalThis.__DISTRIBUTION__ = 'localhost'
|
||||||
|
|
||||||
// Mock Worker for extendable-media-recorder
|
// Mock Worker for extendable-media-recorder
|
||||||
globalThis.Worker = vi.fn().mockImplementation(() => ({
|
globalThis.Worker = vi.fn().mockImplementation(() => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user