mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-26 01:09:46 +00:00
Media Assets Management Sidebar Tab Implementation (#6112)
## 📋 Overview Implemented a new Media Assets sidebar tab in ComfyUI for managing user-uploaded input files and generated output files. This feature supports both local and cloud environments and is currently enabled only in development mode. ## 🎯 Key Features ### 1. Media Assets Sidebar Tab - **Imported** / **Generated** files separated by tabs - Visual display with file preview cards - Gallery view support (navigable with arrow keys) ### 2. Environment-Specific Implementation - **`useInternalMediaAssets`**: For local environment - Fetches file list via `/files` API - Retrieves generation task execution time via `/history` API - Processes history data using the same logic as QueueSidebarTab - **`useCloudMediaAssets`**: For cloud environment - File retrieval through assetService - History data processing using TaskItemImpl - Auto-truncation of long filenames over 20 characters (e.g., `very_long_filename_here.png` → `very_long_...here.png`) ### 3. Execution Time Display - Shows task execution time on generated image cards (e.g., "2.3s") - Calculated from History API's `execution_start` and `execution_success` messages - Displayed at MediaAssetCard's duration chip location ### 4. Gallery Feature - Full-screen gallery mode on image click - Navigate between images with keyboard arrows - Exit gallery with ESC key - Reuses ResultGallery component from QueueSidebarTab ### 5. Development Mode Only - Excluded from production builds using `import.meta.env.DEV` condition - Feature in development, scheduled for official release after stabilization ## 🛠️ Technical Changes ### New Files Added - `src/components/sidebar/tabs/AssetsSidebarTab.vue` - Main sidebar tab component - `src/composables/sidebarTabs/useAssetsSidebarTab.ts` - Sidebar tab definition - `src/composables/useInternalMediaAssets.ts` - Local environment implementation - `src/composables/useCloudMediaAssets.ts` - Cloud environment implementation - `packages/design-system/src/icons/image-ai-edit.svg` - Icon addition ### Modified Files - `src/stores/workspace/sidebarTabStore.ts` - Added dev mode only tab display logic - `src/platform/assets/components/MediaAssetCard.vue` - Added execution time display, zoom event - `src/platform/assets/components/MediaImageTop.vue` - Added image dimension detection - `packages/shared-frontend-utils/src/formatUtil.ts` - Added media type determination utility functions - `src/locales/en/main.json` - Added translation keys [media_asset_OSS_cloud.webm](https://github.com/user-attachments/assets/a6ee3b49-19ed-4735-baad-c2ac2da868ef) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -54,7 +54,7 @@ const {
|
||||
}>()
|
||||
|
||||
const topStyle = computed(() => {
|
||||
const baseClasses = 'relative p-0'
|
||||
const baseClasses = 'relative p-0 overflow-hidden'
|
||||
|
||||
const ratioClasses = {
|
||||
square: 'aspect-square',
|
||||
|
||||
33
src/components/sidebar/tabs/AssetSidebarTemplate.vue
Normal file
33
src/components/sidebar/tabs/AssetSidebarTemplate.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col bg-interface-panel-surface"
|
||||
:class="props.class"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
v-if="slots.top"
|
||||
class="flex min-h-12 items-center border-b border-interface-stroke px-4 py-2"
|
||||
>
|
||||
<slot name="top" />
|
||||
</div>
|
||||
<div v-if="slots.header" class="px-4">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- h-0 to force scrollpanel to grow -->
|
||||
<ScrollPanel class="h-0 grow">
|
||||
<slot name="body" />
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useSlots } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
</script>
|
||||
297
src/components/sidebar/tabs/AssetsSidebarTab.vue
Normal file
297
src/components/sidebar/tabs/AssetsSidebarTab.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<AssetsSidebarTemplate>
|
||||
<template #top>
|
||||
<span v-if="!isInFolderView" class="font-bold">
|
||||
{{ $t('sideToolbar.mediaAssets') }}
|
||||
</span>
|
||||
<div v-else class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ $t('Job ID') }}:</span>
|
||||
<span class="text-sm">{{ folderPromptId?.substring(0, 8) }}</span>
|
||||
<button
|
||||
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
|
||||
role="button"
|
||||
@click="copyJobId"
|
||||
>
|
||||
<i class="mb-1 icon-[lucide--copy] text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ formattedExecutionTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #header>
|
||||
<!-- Job Detail View Header -->
|
||||
<div v-if="isInFolderView" class="pt-4 pb-2">
|
||||
<IconTextButton
|
||||
:label="$t('sideToolbar.backToAssets')"
|
||||
type="secondary"
|
||||
@click="exitFolderView"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-left] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<!-- Normal Tab View -->
|
||||
<TabList v-else v-model="activeTab" class="pt-4 pb-1">
|
||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
||||
</TabList>
|
||||
</template>
|
||||
<template #body>
|
||||
<VirtualGrid
|
||||
v-if="displayAssets.length"
|
||||
:items="mediaAssetsWithKey"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
padding: '0.5rem',
|
||||
gap: '0.5rem'
|
||||
}"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
:asset="item"
|
||||
:selected="selectedAsset?.id === item.id"
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
@click="handleAssetSelect(item)"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@asset-deleted="refreshAssets"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
<div v-else-if="loading">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
$t(
|
||||
activeTab === 'input'
|
||||
? 'sideToolbar.noImportedFiles'
|
||||
: 'sideToolbar.noGeneratedFiles'
|
||||
)
|
||||
"
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</AssetsSidebarTemplate>
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import { t } from '@/i18n'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('input')
|
||||
const selectedAsset = ref<AssetItem | null>(null)
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
|
||||
const getOutputCount = (item: AssetItem): number => {
|
||||
const count = item.user_metadata?.outputCount
|
||||
return typeof count === 'number' && count > 0 ? count : 0
|
||||
}
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
}
|
||||
return getOutputCount(item) > 1
|
||||
}
|
||||
|
||||
const formattedExecutionTime = computed(() => {
|
||||
if (!folderExecutionTime.value) return ''
|
||||
return formatDuration(folderExecutionTime.value * 1000)
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useMediaAssets('input')
|
||||
const outputAssets = useMediaAssets('output')
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
activeTab.value === 'input' ? inputAssets : outputAssets
|
||||
)
|
||||
const loading = computed(() => currentAssets.value.loading.value)
|
||||
const error = computed(() => currentAssets.value.error.value)
|
||||
const mediaAssets = computed(() => currentAssets.value.media.value)
|
||||
|
||||
const galleryActiveIndex = ref(-1)
|
||||
const currentGalleryAssetId = ref<string | null>(null)
|
||||
|
||||
const folderAssets = ref<AssetItem[]>([])
|
||||
|
||||
const displayAssets = computed(() => {
|
||||
if (isInFolderView.value) {
|
||||
return folderAssets.value
|
||||
}
|
||||
return mediaAssets.value
|
||||
})
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
(asset) => asset.id === currentGalleryAssetId.value
|
||||
)
|
||||
if (newIndex !== -1) {
|
||||
galleryActiveIndex.value = newIndex
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(galleryActiveIndex, (index) => {
|
||||
if (index === -1) {
|
||||
currentGalleryAssetId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const galleryItems = computed(() => {
|
||||
return displayAssets.value.map((asset) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '0',
|
||||
mediaType: mediaType === 'image' ? 'images' : mediaType
|
||||
})
|
||||
|
||||
Object.defineProperty(resultItem, 'url', {
|
||||
get() {
|
||||
return asset.preview_url || ''
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
return resultItem
|
||||
})
|
||||
})
|
||||
|
||||
// Add key property for VirtualGrid
|
||||
const mediaAssetsWithKey = computed(() => {
|
||||
return displayAssets.value.map((asset) => ({
|
||||
...asset,
|
||||
key: asset.id
|
||||
}))
|
||||
})
|
||||
|
||||
const refreshAssets = async () => {
|
||||
await currentAssets.value.fetchMediaList()
|
||||
if (error.value) {
|
||||
console.error('Failed to refresh assets:', error.value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
activeTab,
|
||||
() => {
|
||||
void refreshAssets()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleAssetSelect = (asset: AssetItem) => {
|
||||
if (selectedAsset.value?.id === asset.id) {
|
||||
selectedAsset.value = null
|
||||
} else {
|
||||
selectedAsset.value = asset
|
||||
}
|
||||
}
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
currentGalleryAssetId.value = asset.id
|
||||
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
if (index !== -1) {
|
||||
galleryActiveIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
const enterFolderView = (asset: AssetItem) => {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!metadata) {
|
||||
console.warn('Invalid output asset metadata')
|
||||
return
|
||||
}
|
||||
|
||||
const { promptId, allOutputs, executionTimeInSeconds } = metadata
|
||||
|
||||
if (!promptId || !Array.isArray(allOutputs) || allOutputs.length === 0) {
|
||||
console.warn('Missing required folder view data')
|
||||
return
|
||||
}
|
||||
|
||||
folderPromptId.value = promptId
|
||||
folderExecutionTime.value = executionTimeInSeconds
|
||||
|
||||
folderAssets.value = allOutputs.map((output) => ({
|
||||
id: `${output.nodeId}-${output.filename}`,
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: asset.created_at,
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: {
|
||||
promptId,
|
||||
nodeId: output.nodeId,
|
||||
subfolder: output.subfolder,
|
||||
executionTimeInSeconds,
|
||||
workflow: metadata.workflow
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const exitFolderView = () => {
|
||||
folderPromptId.value = null
|
||||
folderExecutionTime.value = undefined
|
||||
folderAssets.value = []
|
||||
}
|
||||
|
||||
const copyJobId = async () => {
|
||||
if (folderPromptId.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(folderPromptId.value)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('mediaAsset.jobIdToast.copied'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopied'),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('mediaAsset.jobIdToast.error'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
48
src/components/tab/Tab.vue
Normal file
48
src/components/tab/Tab.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<button
|
||||
:id="tabId"
|
||||
:class="tabClasses"
|
||||
role="tab"
|
||||
:aria-selected="isActive"
|
||||
:aria-controls="panelId"
|
||||
:tabindex="0"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { value, panelId } = defineProps<{
|
||||
value: string
|
||||
panelId?: string
|
||||
}>()
|
||||
|
||||
const currentValue = inject<Ref<string>>('tabs-value')
|
||||
const updateValue = inject<(value: string) => void>('tabs-update')
|
||||
|
||||
const tabId = computed(() => `tab-${value}`)
|
||||
const isActive = computed(() => currentValue?.value === value)
|
||||
|
||||
const tabClasses = computed(() => {
|
||||
return cn(
|
||||
// Base styles from TextButton
|
||||
'flex items-center justify-center shrink-0',
|
||||
'px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200',
|
||||
'outline-hidden border-none',
|
||||
// State styles with semantic tokens
|
||||
isActive.value
|
||||
? 'bg-interface-menu-component-surface-hovered text-text-primary text-bold'
|
||||
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||
)
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
updateValue?.(value)
|
||||
}
|
||||
</script>
|
||||
153
src/components/tab/TabList.stories.ts
Normal file
153
src/components/tab/TabList.stories.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Tab from './Tab.vue'
|
||||
import TabList from './TabList.vue'
|
||||
|
||||
const meta: Meta<typeof TabList> = {
|
||||
title: 'Components/Tab/TabList',
|
||||
component: TabList,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text',
|
||||
description: 'The currently selected tab value'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref(args.modelValue || 'tab1')
|
||||
return { activeTab }
|
||||
},
|
||||
template: `
|
||||
<TabList v-model="activeTab">
|
||||
<Tab value="tab1">Tab 1</Tab>
|
||||
<Tab value="tab2">Tab 2</Tab>
|
||||
<Tab value="tab3">Tab 3</Tab>
|
||||
</TabList>
|
||||
<div class="mt-4 p-4 border rounded">
|
||||
Selected tab: {{ activeTab }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: 'tab1'
|
||||
}
|
||||
}
|
||||
|
||||
export const ManyTabs: Story = {
|
||||
render: () => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref('tab1')
|
||||
return { activeTab }
|
||||
},
|
||||
template: `
|
||||
<TabList v-model="activeTab">
|
||||
<Tab value="tab1">Dashboard</Tab>
|
||||
<Tab value="tab2">Analytics</Tab>
|
||||
<Tab value="tab3">Reports</Tab>
|
||||
<Tab value="tab4">Settings</Tab>
|
||||
<Tab value="tab5">Profile</Tab>
|
||||
</TabList>
|
||||
<div class="mt-4 p-4 border rounded">
|
||||
Selected tab: {{ activeTab }}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render: () => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref('home')
|
||||
return { activeTab }
|
||||
},
|
||||
template: `
|
||||
<TabList v-model="activeTab">
|
||||
<Tab value="home">
|
||||
<i class="pi pi-home mr-2"></i>
|
||||
Home
|
||||
</Tab>
|
||||
<Tab value="users">
|
||||
<i class="pi pi-users mr-2"></i>
|
||||
Users
|
||||
</Tab>
|
||||
<Tab value="settings">
|
||||
<i class="pi pi-cog mr-2"></i>
|
||||
Settings
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div class="mt-4 p-4 border rounded">
|
||||
Selected tab: {{ activeTab }}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const LongLabels: Story = {
|
||||
render: () => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref('overview')
|
||||
return { activeTab }
|
||||
},
|
||||
template: `
|
||||
<TabList v-model="activeTab">
|
||||
<Tab value="overview">Project Overview</Tab>
|
||||
<Tab value="documentation">Documentation & Guides</Tab>
|
||||
<Tab value="deployment">Deployment Settings</Tab>
|
||||
<Tab value="monitoring">Monitoring & Analytics</Tab>
|
||||
</TabList>
|
||||
<div class="mt-4 p-4 border rounded">
|
||||
Selected tab: {{ activeTab }}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: () => ({
|
||||
components: { TabList, Tab },
|
||||
setup() {
|
||||
const activeTab = ref('input')
|
||||
const handleTabChange = (value: string) => {
|
||||
console.log('Tab changed to:', value)
|
||||
}
|
||||
return { activeTab, handleTabChange }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Example: Media Assets</h3>
|
||||
<TabList v-model="activeTab" @update:model-value="handleTabChange">
|
||||
<Tab value="input">Imported</Tab>
|
||||
<Tab value="output">Generated</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div v-if="activeTab === 'input'">
|
||||
<p>Showing imported assets...</p>
|
||||
</div>
|
||||
<div v-else-if="activeTab === 'output'">
|
||||
<p>Showing generated assets...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600">
|
||||
Current tab value: <code>{{ activeTab }}</code>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
17
src/components/tab/TabList.vue
Normal file
17
src/components/tab/TabList.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div role="tablist" class="flex w-full items-center gap-2 pb-1">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { provide } from 'vue'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
// Provide for child Tab components
|
||||
provide('tabs-value', modelValue)
|
||||
provide('tabs-update', (value: string) => {
|
||||
modelValue.value = value
|
||||
})
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
@@ -37,6 +38,13 @@ const uploadFile = async (
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
// Update AssetsStore input assets when files are uploaded to input folder
|
||||
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
|
||||
const assetsStore = useAssetsStore()
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
|
||||
16
src/composables/sidebarTabs/useAssetsSidebarTab.ts
Normal file
16
src/composables/sidebarTabs/useAssetsSidebarTab.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
return {
|
||||
id: 'assets',
|
||||
icon: 'icon-[comfy--image-ai-edit]',
|
||||
title: 'sideToolbar.assets',
|
||||
tooltip: 'sideToolbar.assets',
|
||||
label: 'sideToolbar.labels.assets',
|
||||
component: markRaw(AssetsSidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
@@ -599,6 +599,9 @@
|
||||
"nodeLibrary": "Node Library",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"assets": "Assets",
|
||||
"mediaAssets": "Media Assets",
|
||||
"backToAssets": "Back to all assets",
|
||||
"labels": {
|
||||
"queue": "Queue",
|
||||
"nodes": "Nodes",
|
||||
@@ -606,8 +609,15 @@
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"console": "Console",
|
||||
"menu": "Menu"
|
||||
"menu": "Menu",
|
||||
"assets": "Assets",
|
||||
"imported": "Imported",
|
||||
"generated": "Generated"
|
||||
},
|
||||
"noFilesFound": "No files found",
|
||||
"noImportedFiles": "No imported files found",
|
||||
"noGeneratedFiles": "No generated files found",
|
||||
"noFilesFoundMessage": "Upload files or generate content to see them here",
|
||||
"browseTemplates": "Browse example templates",
|
||||
"openWorkflow": "Open workflow in local file system",
|
||||
"newBlankWorkflow": "Create a new blank workflow",
|
||||
@@ -1760,7 +1770,24 @@
|
||||
"sortAZ": "A-Z",
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular"
|
||||
"sortPopular": "Popular",
|
||||
"ariaLabel": {
|
||||
"assetCard": "{name} - {type} asset",
|
||||
"loadingAsset": "Loading asset"
|
||||
}
|
||||
},
|
||||
"mediaAsset": {
|
||||
"deleteAssetTitle": "Delete this asset?",
|
||||
"deleteAssetDescription": "This asset will be permanently removed.",
|
||||
"assetDeletedSuccessfully": "Asset deleted successfully",
|
||||
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
|
||||
"failedToDeleteAsset": "Failed to delete asset",
|
||||
"jobIdToast": {
|
||||
"jobIdCopied": "Job ID copied to clipboard",
|
||||
"jobIdCopyFailed": "Failed to copy Job ID",
|
||||
"copied": "Copied",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"actionbar": {
|
||||
"dockToTop": "Dock to top"
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
@@ -18,6 +13,7 @@ import { computed } from 'vue'
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<div class="relative size-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
class="flex size-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--box] text-3xl text-zinc-600 dark-theme:text-zinc-200"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<IconGroup>
|
||||
<IconButton size="sm" @click="handleDelete">
|
||||
<IconButton v-if="showDeleteButton" size="sm" @click="handleDelete">
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
<IconButton size="sm" @click="handleDownload">
|
||||
@@ -12,18 +12,23 @@
|
||||
@menu-closed="emit('menuStateChanged', false)"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetMoreMenu :close="close" />
|
||||
<MediaAssetMoreMenu
|
||||
:close="close"
|
||||
@inspect="emit('inspect')"
|
||||
@asset-deleted="emit('asset-deleted')"
|
||||
/>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
@@ -31,20 +36,35 @@ import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
menuStateChanged: [isOpen: boolean]
|
||||
inspect: []
|
||||
'asset-deleted': []
|
||||
}>()
|
||||
|
||||
const { asset } = inject(MediaAssetKey)!
|
||||
const { asset, context } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const handleDelete = () => {
|
||||
if (asset.value) {
|
||||
actions.deleteAsset(asset.value.id)
|
||||
const assetType = computed(() => {
|
||||
return context?.value?.type || asset.value?.tags?.[0] || 'output'
|
||||
})
|
||||
|
||||
const showDeleteButton = computed(() => {
|
||||
return (
|
||||
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
|
||||
)
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!asset.value) return
|
||||
|
||||
const success = await actions.confirmDelete(asset.value)
|
||||
if (success) {
|
||||
emit('asset-deleted')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
actions.downloadAsset()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { AssetItem } from '../schemas/assetSchema'
|
||||
import MediaAssetCard from './MediaAssetCard.vue'
|
||||
|
||||
const meta: Meta<typeof MediaAssetCard> = {
|
||||
@@ -28,10 +28,6 @@ const meta: Meta<typeof MediaAssetCard> = {
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
context: {
|
||||
control: 'select',
|
||||
options: ['input', 'output']
|
||||
},
|
||||
loading: {
|
||||
control: 'boolean'
|
||||
}
|
||||
@@ -53,19 +49,20 @@ const SAMPLE_MEDIA = {
|
||||
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
|
||||
}
|
||||
|
||||
const sampleAsset: AssetMeta = {
|
||||
const sampleAsset: AssetItem = {
|
||||
id: 'asset-1',
|
||||
name: 'sample-image.png',
|
||||
kind: 'image',
|
||||
duration: 3345,
|
||||
size: 2048576,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image1,
|
||||
dimensions: {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
tags: []
|
||||
created_at: new Date().toISOString(),
|
||||
preview_url: SAMPLE_MEDIA.image1,
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 3345,
|
||||
dimensions: {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageAsset: Story = {
|
||||
@@ -75,7 +72,6 @@ export const ImageAsset: Story = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'output', outputCount: 3 },
|
||||
asset: sampleAsset,
|
||||
loading: false
|
||||
}
|
||||
@@ -88,19 +84,18 @@ export const VideoAsset: Story = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-2',
|
||||
name: 'Big_Buck_Bunny.mp4',
|
||||
kind: 'video',
|
||||
size: 10485760,
|
||||
duration: 13425,
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
|
||||
src: SAMPLE_MEDIA.video, // Actual video file
|
||||
dimensions: {
|
||||
width: 1280,
|
||||
height: 720
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail,
|
||||
user_metadata: {
|
||||
duration: 13425,
|
||||
dimensions: {
|
||||
width: 1280,
|
||||
height: 720
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,16 +108,15 @@ export const Model3DAsset: Story = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-3',
|
||||
name: 'Asset-3d-model.glb',
|
||||
kind: '3D',
|
||||
size: 7340032,
|
||||
src: '',
|
||||
dimensions: undefined,
|
||||
duration: 18023
|
||||
preview_url: '',
|
||||
user_metadata: {
|
||||
duration: 18023
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,16 +128,15 @@ export const AudioAsset: Story = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-3',
|
||||
id: 'asset-4',
|
||||
name: 'SoundHelix-Song.mp3',
|
||||
kind: 'audio',
|
||||
size: 5242880,
|
||||
src: SAMPLE_MEDIA.audio,
|
||||
dimensions: undefined,
|
||||
duration: 23180
|
||||
preview_url: SAMPLE_MEDIA.audio,
|
||||
user_metadata: {
|
||||
duration: 23180
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +148,6 @@ export const LoadingState: Story = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: sampleAsset,
|
||||
loading: true
|
||||
}
|
||||
@@ -168,7 +160,6 @@ export const LongFileName: Story = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png'
|
||||
@@ -183,7 +174,6 @@ export const SelectedState: Story = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'output', outputCount: 2 },
|
||||
asset: sampleAsset,
|
||||
selected: true
|
||||
}
|
||||
@@ -196,21 +186,20 @@ export const WebMVideo: Story = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
id: 'asset-webm',
|
||||
name: 'animated-clip.webm',
|
||||
kind: 'video',
|
||||
size: 3145728,
|
||||
created_at: Date.now().toString(),
|
||||
preview_url: SAMPLE_MEDIA.image1, // Poster image
|
||||
src: 'https://www.w3schools.com/html/movie.mp4', // Actual video
|
||||
duration: 620,
|
||||
dimensions: {
|
||||
width: 640,
|
||||
height: 360
|
||||
},
|
||||
tags: []
|
||||
created_at: new Date().toISOString(),
|
||||
preview_url: SAMPLE_MEDIA.image1,
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 620,
|
||||
dimensions: {
|
||||
width: 640,
|
||||
height: 360
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,20 +211,20 @@ export const GifAnimation: Story = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
id: 'asset-gif',
|
||||
name: 'animation.gif',
|
||||
kind: 'image',
|
||||
size: 1572864,
|
||||
duration: 1345,
|
||||
created_at: Date.now().toString(),
|
||||
src: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
|
||||
dimensions: {
|
||||
width: 480,
|
||||
height: 270
|
||||
},
|
||||
tags: []
|
||||
created_at: new Date().toISOString(),
|
||||
preview_url: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 1345,
|
||||
dimensions: {
|
||||
width: 480,
|
||||
height: 270
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,83 +233,89 @@ export const GridLayout: Story = {
|
||||
render: () => ({
|
||||
components: { MediaAssetCard },
|
||||
setup() {
|
||||
const assets: AssetMeta[] = [
|
||||
const assets: AssetItem[] = [
|
||||
{
|
||||
id: 'grid-1',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image1,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
created_at: new Date().toISOString(),
|
||||
preview_url: SAMPLE_MEDIA.image1,
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 4500,
|
||||
dimensions: { width: 1920, height: 1080 }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'grid-2',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image2,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
created_at: new Date().toISOString(),
|
||||
preview_url: SAMPLE_MEDIA.image2,
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 4500,
|
||||
dimensions: { width: 1920, height: 1080 }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'grid-3',
|
||||
name: 'video-file.mp4',
|
||||
kind: 'video',
|
||||
size: 10485760,
|
||||
duration: 13425,
|
||||
created_at: Date.now().toString(),
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
|
||||
src: SAMPLE_MEDIA.video, // Actual video
|
||||
dimensions: { width: 1280, height: 720 },
|
||||
tags: []
|
||||
created_at: new Date().toISOString(),
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail,
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 13425,
|
||||
dimensions: { width: 1280, height: 720 }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'grid-4',
|
||||
name: 'audio-file.mp3',
|
||||
kind: 'audio',
|
||||
size: 5242880,
|
||||
duration: 180,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.audio,
|
||||
tags: []
|
||||
created_at: new Date().toISOString(),
|
||||
preview_url: SAMPLE_MEDIA.audio,
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 180
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'grid-5',
|
||||
name: 'animation.gif',
|
||||
kind: 'image',
|
||||
size: 3145728,
|
||||
duration: 1345,
|
||||
created_at: Date.now().toString(),
|
||||
src: 'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
|
||||
dimensions: { width: 480, height: 360 },
|
||||
tags: []
|
||||
created_at: new Date().toISOString(),
|
||||
preview_url:
|
||||
'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 1345,
|
||||
dimensions: { width: 480, height: 360 }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'grid-6',
|
||||
name: 'Asset-3d-model.glb',
|
||||
kind: '3D',
|
||||
size: 7340032,
|
||||
src: '',
|
||||
dimensions: undefined,
|
||||
duration: 18023,
|
||||
created_at: Date.now().toString(),
|
||||
tags: []
|
||||
preview_url: '',
|
||||
created_at: new Date().toISOString(),
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 18023
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'grid-7',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image3,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
created_at: new Date().toISOString(),
|
||||
preview_url: SAMPLE_MEDIA.image3,
|
||||
tags: ['input'],
|
||||
user_metadata: {
|
||||
duration: 4500,
|
||||
dimensions: { width: 1920, height: 1080 }
|
||||
}
|
||||
}
|
||||
]
|
||||
return { assets }
|
||||
@@ -330,7 +325,6 @@ export const GridLayout: Story = {
|
||||
<MediaAssetCard
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
:context="{ type: Math.random() > 0.5 ? 'input' : 'output', outputCount: Math.floor(Math.random() * 5) }"
|
||||
:asset="asset"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
ref="cardContainerRef"
|
||||
role="button"
|
||||
:aria-label="
|
||||
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
|
||||
asset
|
||||
? $t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: asset.name,
|
||||
type: fileKind
|
||||
})
|
||||
: $t('assetBrowser.ariaLabel.loadingAsset')
|
||||
"
|
||||
:tabindex="loading ? -1 : 0"
|
||||
size="mini"
|
||||
@@ -28,16 +33,17 @@
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<template v-else-if="asset && adaptedAsset">
|
||||
<component
|
||||
:is="getTopComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
:is="getTopComponent(fileKind)"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
@view="handleZoomClick"
|
||||
@download="actions.downloadAsset(asset!.id)"
|
||||
@play="actions.playAsset(asset!.id)"
|
||||
@download="actions.downloadAsset()"
|
||||
@play="actions.playAsset(asset.id)"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
@image-loaded="handleImageLoaded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -45,6 +51,8 @@
|
||||
<template v-if="showActionsOverlay" #top-left>
|
||||
<MediaAssetActions
|
||||
@menu-state-changed="isMenuOpen = $event"
|
||||
@inspect="handleZoomClick"
|
||||
@asset-deleted="handleAssetDelete"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
/>
|
||||
@@ -63,13 +71,17 @@
|
||||
</template>
|
||||
|
||||
<!-- Duration/Format chips (bottom-left) - show on hover even when playing -->
|
||||
<template v-if="showDurationChips" #bottom-left>
|
||||
<template v-if="showDurationChips || showFileFormatChip" #bottom-left>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<SquareChip variant="light" :label="formattedDuration" />
|
||||
<SquareChip
|
||||
v-if="formattedDuration"
|
||||
variant="light"
|
||||
:label="formattedDuration"
|
||||
/>
|
||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -79,8 +91,8 @@
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:label="context?.outputCount?.toString() ?? '0'"
|
||||
@click.stop="actions.openMoreOutputs(asset?.id || '')"
|
||||
:label="String(outputCount)"
|
||||
@click.stop="handleOutputCountClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
@@ -107,11 +119,11 @@
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<template v-else-if="asset && adaptedAsset">
|
||||
<component
|
||||
:is="getBottomComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
:is="getBottomComponent(fileKind)"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
/>
|
||||
</template>
|
||||
</CardBottom>
|
||||
@@ -129,16 +141,13 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import { formatDuration } from '@/utils/formatUtil'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import type {
|
||||
AssetContext,
|
||||
AssetMeta,
|
||||
MediaKind
|
||||
} from '../schemas/mediaAssetSchema'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetActions from './MediaAssetActions.vue'
|
||||
|
||||
@@ -165,11 +174,18 @@ function getBottomComponent(kind: MediaKind) {
|
||||
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
|
||||
}
|
||||
|
||||
const { context, asset, loading, selected } = defineProps<{
|
||||
context: AssetContext
|
||||
asset?: AssetMeta
|
||||
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||
asset?: AssetItem
|
||||
loading?: boolean
|
||||
selected?: boolean
|
||||
showOutputCount?: boolean
|
||||
outputCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
zoom: [asset: AssetItem]
|
||||
'output-count-click': []
|
||||
'asset-deleted': []
|
||||
}>()
|
||||
|
||||
const cardContainerRef = ref<HTMLElement>()
|
||||
@@ -179,14 +195,44 @@ const isMenuOpen = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
const isOverlayHovered = ref(false)
|
||||
|
||||
// Store actual image dimensions
|
||||
const imageDimensions = ref<{ width: number; height: number } | undefined>()
|
||||
|
||||
const isHovered = useElementHover(cardContainerRef)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
|
||||
// Get asset type from tags
|
||||
const assetType = computed(() => {
|
||||
return getAssetType(asset?.tags)
|
||||
})
|
||||
|
||||
// Determine file type from extension
|
||||
const fileKind = computed((): MediaKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
|
||||
})
|
||||
|
||||
// Adapt AssetItem to legacy AssetMeta format for existing components
|
||||
const adaptedAsset = computed(() => {
|
||||
if (!asset) return undefined
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
kind: fileKind.value,
|
||||
src: asset.preview_url || '',
|
||||
size: asset.size,
|
||||
tags: asset.tags || [],
|
||||
created_at: asset.created_at,
|
||||
duration: asset.user_metadata?.duration
|
||||
? Number(asset.user_metadata.duration)
|
||||
: undefined,
|
||||
dimensions: imageDimensions.value
|
||||
}
|
||||
})
|
||||
|
||||
provide(MediaAssetKey, {
|
||||
asset: toRef(() => asset),
|
||||
context: toRef(() => context),
|
||||
asset: toRef(() => adaptedAsset.value),
|
||||
context: toRef(() => ({ type: assetType.value })),
|
||||
isVideoPlaying,
|
||||
showVideoControls
|
||||
})
|
||||
@@ -201,8 +247,16 @@ const containerClasses = computed(() =>
|
||||
)
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
if (!asset?.duration) return ''
|
||||
return formatDuration(asset.duration)
|
||||
// Check for execution time first (from history API)
|
||||
const executionTime = asset?.user_metadata?.executionTimeInSeconds
|
||||
if (executionTime !== undefined && executionTime !== null) {
|
||||
return `${Number(executionTime).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// Fall back to duration for media files
|
||||
const duration = asset?.user_metadata?.duration
|
||||
if (!duration) return ''
|
||||
return formatDuration(Number(duration))
|
||||
})
|
||||
|
||||
const fileFormat = computed(() => {
|
||||
@@ -212,10 +266,10 @@ const fileFormat = computed(() => {
|
||||
})
|
||||
|
||||
const durationChipClasses = computed(() => {
|
||||
if (asset?.kind === 'audio') {
|
||||
if (fileKind.value === 'audio') {
|
||||
return '-translate-y-11'
|
||||
}
|
||||
if (asset?.kind === 'video' && showVideoControls.value) {
|
||||
if (fileKind.value === 'video' && showVideoControls.value) {
|
||||
return '-translate-y-16'
|
||||
}
|
||||
return ''
|
||||
@@ -238,27 +292,29 @@ const showActionsOverlay = computed(
|
||||
const showZoomOverlay = computed(
|
||||
() =>
|
||||
showHoverActions.value &&
|
||||
asset?.kind !== '3D' &&
|
||||
fileKind.value !== '3D' &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showDurationChips = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
asset?.duration &&
|
||||
(asset?.user_metadata?.executionTimeInSeconds ||
|
||||
asset?.user_metadata?.duration) &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showOutputCount = computed(
|
||||
const showFileFormatChip = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
context?.outputCount &&
|
||||
!!asset &&
|
||||
!!fileFormat.value &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (asset) {
|
||||
actions.selectAsset(asset)
|
||||
if (adaptedAsset.value) {
|
||||
actions.selectAsset(adaptedAsset.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +328,19 @@ const handleOverlayMouseLeave = () => {
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
galleryStore.openSingle(asset)
|
||||
emit('zoom', asset)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageLoaded = (width: number, height: number) => {
|
||||
imageDimensions.value = { width, height }
|
||||
}
|
||||
|
||||
const handleOutputCountClick = () => {
|
||||
emit('output-count-click')
|
||||
}
|
||||
|
||||
const handleAssetDelete = () => {
|
||||
emit('asset-deleted')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Add to current workflow"
|
||||
@@ -34,7 +35,7 @@
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider />
|
||||
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
@@ -60,9 +61,10 @@
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
|
||||
<MediaAssetButtonDivider v-if="showWorkflowOptions && showCopyJobId" />
|
||||
|
||||
<IconTextButton
|
||||
v-if="showCopyJobId"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Copy job ID"
|
||||
@@ -73,9 +75,10 @@
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider />
|
||||
<MediaAssetButtonDivider v-if="showCopyJobId && showDeleteButton" />
|
||||
|
||||
<IconTextButton
|
||||
v-if="showDeleteButton"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Delete"
|
||||
@@ -92,9 +95,9 @@
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
||||
|
||||
@@ -102,16 +105,36 @@ const { close } = defineProps<{
|
||||
close: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
inspect: []
|
||||
'asset-deleted': []
|
||||
}>()
|
||||
|
||||
const { asset, context } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
|
||||
const showWorkflowOptions = computed(() => context.value.type)
|
||||
const assetType = computed(() => {
|
||||
return asset.value?.tags?.[0] || context.value?.type || 'output'
|
||||
})
|
||||
|
||||
const showWorkflowOptions = computed(() => assetType.value === 'output')
|
||||
|
||||
// Only show Copy Job ID for output assets (not for imported/input assets)
|
||||
const showCopyJobId = computed(() => {
|
||||
return assetType.value !== 'input'
|
||||
})
|
||||
|
||||
// Delete button should be shown for:
|
||||
// - All output files (can be deleted via history)
|
||||
// - Input files only in cloud environment
|
||||
const showDeleteButton = computed(() => {
|
||||
return (
|
||||
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
|
||||
)
|
||||
})
|
||||
|
||||
const handleInspect = () => {
|
||||
if (asset.value) {
|
||||
galleryStore.openSingle(asset.value)
|
||||
}
|
||||
emit('inspect')
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -124,7 +147,7 @@ const handleAddToWorkflow = () => {
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
actions.downloadAsset()
|
||||
}
|
||||
close()
|
||||
}
|
||||
@@ -143,17 +166,21 @@ const handleExportWorkflow = () => {
|
||||
close()
|
||||
}
|
||||
|
||||
const handleCopyJobId = () => {
|
||||
const handleCopyJobId = async () => {
|
||||
if (asset.value) {
|
||||
actions.copyAssetUrl(asset.value.id)
|
||||
await actions.copyJobId()
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (asset.value) {
|
||||
actions.deleteAsset(asset.value.id)
|
||||
const handleDelete = async () => {
|
||||
if (!asset.value) return
|
||||
|
||||
close() // Close the menu first
|
||||
|
||||
const success = await actions.confirmDelete(asset.value)
|
||||
if (success) {
|
||||
emit('asset-deleted')
|
||||
}
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
@@ -18,6 +13,7 @@ import { computed } from 'vue'
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<div class="relative size-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
class="flex size-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--music] text-3xl text-zinc-600 dark-theme:text-zinc-200"
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
|
||||
<span v-if="asset.dimensions"
|
||||
>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,6 +15,7 @@ import { computed } from 'vue'
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<LazyImage
|
||||
v-if="asset.src"
|
||||
<div
|
||||
class="relative size-full overflow-hidden rounded bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<img
|
||||
v-if="!error"
|
||||
:src="asset.src"
|
||||
:alt="asset.name"
|
||||
:container-class="'aspect-square'"
|
||||
:image-class="'w-full h-full object-cover'"
|
||||
class="size-full object-contain"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
class="flex size-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i class="pi pi-image text-3xl text-smoke-400" />
|
||||
</div>
|
||||
@@ -17,11 +18,27 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import { useImage, whenever } from '@vueuse/core'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'image-loaded': [width: number, height: number]
|
||||
}>()
|
||||
|
||||
const { state, error, isReady } = useImage({
|
||||
src: asset.src ?? '',
|
||||
alt: asset.name
|
||||
})
|
||||
|
||||
whenever(
|
||||
() =>
|
||||
isReady.value && state.value?.naturalWidth && state.value?.naturalHeight,
|
||||
() =>
|
||||
emit('image-loaded', state.value!.naturalWidth, state.value!.naturalHeight)
|
||||
)
|
||||
</script>
|
||||
|
||||
21
src/platform/assets/components/MediaTitle.vue
Normal file
21
src/platform/assets/components/MediaTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="fullName"
|
||||
>
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { truncateFilename } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
fileName: string
|
||||
}>()
|
||||
|
||||
const fullName = computed(() => props.fileName)
|
||||
const displayName = computed(() => truncateFilename(props.fileName))
|
||||
</script>
|
||||
@@ -1,13 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,9 +10,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden rounded bg-black"
|
||||
class="relative size-full overflow-hidden rounded bg-black"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
@@ -9,7 +9,7 @@
|
||||
:controls="shouldShowControls"
|
||||
preload="none"
|
||||
:poster="asset.preview_url"
|
||||
class="relative h-full w-full object-contain"
|
||||
class="relative size-full object-contain"
|
||||
@click.stop
|
||||
@play="onVideoPlay"
|
||||
@pause="onVideoPause"
|
||||
|
||||
29
src/platform/assets/composables/media/IAssetsProvider.ts
Normal file
29
src/platform/assets/composables/media/IAssetsProvider.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Interface for media assets providers
|
||||
* Defines the common API for both cloud and internal file implementations
|
||||
*/
|
||||
export interface IAssetsProvider {
|
||||
/** Current media assets */
|
||||
media: Ref<AssetItem[]>
|
||||
|
||||
/** Loading state indicator */
|
||||
loading: Ref<boolean>
|
||||
|
||||
/** Error state */
|
||||
error: Ref<unknown>
|
||||
|
||||
/**
|
||||
* Fetch list of media assets
|
||||
* @returns Promise resolving to array of AssetItem
|
||||
*/
|
||||
fetchMediaList: () => Promise<AssetItem[]>
|
||||
|
||||
/**
|
||||
* Refresh the media list (alias for fetchMediaList)
|
||||
*/
|
||||
refresh: () => Promise<AssetItem[]>
|
||||
}
|
||||
73
src/platform/assets/composables/media/assetMappers.ts
Normal file
73
src/platform/assets/composables/media/assetMappers.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
* Extract asset type from tags array
|
||||
* @param tags The tags array from AssetItem
|
||||
* @returns The asset type ('input' or 'output')
|
||||
*/
|
||||
export function getAssetType(tags?: string[]): AssetContext['type'] {
|
||||
const tag = tags?.[0]
|
||||
if (tag === 'output') return 'output'
|
||||
return 'input'
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a TaskItemImpl output to an AssetItem format
|
||||
* @param taskItem The task item containing execution data
|
||||
* @param output The output from the task
|
||||
* @param useDisplayName Whether to truncate the filename for display
|
||||
* @returns AssetItem formatted object
|
||||
*/
|
||||
export function mapTaskOutputToAssetItem(
|
||||
taskItem: TaskItemImpl,
|
||||
output: ResultItemImpl
|
||||
): AssetItem {
|
||||
const metadata: OutputAssetMetadata = {
|
||||
promptId: taskItem.promptId,
|
||||
nodeId: output.nodeId,
|
||||
subfolder: output.subfolder,
|
||||
executionTimeInSeconds: taskItem.executionTimeInSeconds,
|
||||
format: output.format,
|
||||
workflow: taskItem.workflow
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskItem.promptId,
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: taskItem.executionStartTimestamp
|
||||
? new Date(taskItem.executionStartTimestamp).toISOString()
|
||||
: new Date().toISOString(),
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: metadata
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps input directory file to AssetItem format
|
||||
* @param filename The filename
|
||||
* @param index File index for unique ID
|
||||
* @param directory The directory type
|
||||
* @returns AssetItem formatted object
|
||||
*/
|
||||
export function mapInputFileToAssetItem(
|
||||
filename: string,
|
||||
index: number,
|
||||
directory: 'input' | 'output' = 'input'
|
||||
): AssetItem {
|
||||
return {
|
||||
id: `${directory}-${index}-${filename}`,
|
||||
name: filename,
|
||||
size: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: [directory],
|
||||
preview_url: api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(filename)}&type=${directory}`
|
||||
)
|
||||
}
|
||||
}
|
||||
46
src/platform/assets/composables/media/useAssetsApi.ts
Normal file
46
src/platform/assets/composables/media/useAssetsApi.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
/**
|
||||
* Composable for fetching media assets from cloud environment
|
||||
* Uses AssetsStore for centralized state management
|
||||
*/
|
||||
export function useAssetsApi(directory: 'input' | 'output') {
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
const media = computed(() =>
|
||||
directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
|
||||
)
|
||||
|
||||
const loading = computed(() =>
|
||||
directory === 'input'
|
||||
? assetsStore.inputLoading
|
||||
: assetsStore.historyLoading
|
||||
)
|
||||
|
||||
const error = computed(() =>
|
||||
directory === 'input' ? assetsStore.inputError : assetsStore.historyError
|
||||
)
|
||||
|
||||
const fetchMediaList = async (): Promise<AssetItem[]> => {
|
||||
if (directory === 'input') {
|
||||
await assetsStore.updateInputs()
|
||||
return assetsStore.inputAssets
|
||||
} else {
|
||||
await assetsStore.updateHistory()
|
||||
return assetsStore.historyAssets
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = () => fetchMediaList()
|
||||
|
||||
return {
|
||||
media,
|
||||
loading,
|
||||
error,
|
||||
fetchMediaList,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
46
src/platform/assets/composables/media/useInternalFilesApi.ts
Normal file
46
src/platform/assets/composables/media/useInternalFilesApi.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
/**
|
||||
* Composable for fetching media assets from local environment
|
||||
* Uses AssetsStore for centralized state management
|
||||
*/
|
||||
export function useInternalFilesApi(directory: 'input' | 'output') {
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
const media = computed(() =>
|
||||
directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
|
||||
)
|
||||
|
||||
const loading = computed(() =>
|
||||
directory === 'input'
|
||||
? assetsStore.inputLoading
|
||||
: assetsStore.historyLoading
|
||||
)
|
||||
|
||||
const error = computed(() =>
|
||||
directory === 'input' ? assetsStore.inputError : assetsStore.historyError
|
||||
)
|
||||
|
||||
const fetchMediaList = async (): Promise<AssetItem[]> => {
|
||||
if (directory === 'input') {
|
||||
await assetsStore.updateInputs()
|
||||
return assetsStore.inputAssets
|
||||
} else {
|
||||
await assetsStore.updateHistory()
|
||||
return assetsStore.historyAssets
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = () => fetchMediaList()
|
||||
|
||||
return {
|
||||
media,
|
||||
loading,
|
||||
error,
|
||||
fetchMediaList,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
15
src/platform/assets/composables/media/useMediaAssets.ts
Normal file
15
src/platform/assets/composables/media/useMediaAssets.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import type { IAssetsProvider } from './IAssetsProvider'
|
||||
import { useAssetsApi } from './useAssetsApi'
|
||||
import { useInternalFilesApi } from './useInternalFilesApi'
|
||||
|
||||
/**
|
||||
* Factory function that returns the appropriate media assets implementation
|
||||
* based on the current distribution (cloud vs internal)
|
||||
* @param directory - The directory to fetch assets from
|
||||
* @returns IAssetsProvider implementation
|
||||
*/
|
||||
export function useMediaAssets(directory: 'input' | 'output'): IAssetsProvider {
|
||||
return isCloud ? useAssetsApi(directory) : useInternalFilesApi(directory)
|
||||
}
|
||||
@@ -1,29 +1,191 @@
|
||||
/* eslint-disable no-console */
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { inject } from 'vue'
|
||||
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import { assetService } from '../services/assetService'
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const mediaContext = inject(MediaAssetKey, null)
|
||||
|
||||
const selectAsset = (asset: AssetMeta) => {
|
||||
console.log('Asset selected:', asset)
|
||||
}
|
||||
|
||||
const downloadAsset = (assetId: string) => {
|
||||
console.log('Downloading asset:', assetId)
|
||||
const downloadAsset = () => {
|
||||
const asset = mediaContext?.asset.value
|
||||
if (!asset) return
|
||||
|
||||
try {
|
||||
const assetType = asset.tags?.[0] || 'output'
|
||||
const filename = asset.name
|
||||
const downloadUrl = api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(filename)}&type=${assetType}`
|
||||
)
|
||||
|
||||
downloadFile(downloadUrl, filename)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('g.downloadStarted'),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAsset = (assetId: string) => {
|
||||
console.log('Deleting asset:', assetId)
|
||||
/**
|
||||
* Show confirmation dialog and delete asset if confirmed
|
||||
* @param asset The asset to delete
|
||||
* @returns true if the asset was deleted, false otherwise
|
||||
*/
|
||||
const confirmDelete = async (asset: AssetItem): Promise<boolean> => {
|
||||
const assetType = asset.tags?.[0] || 'output'
|
||||
|
||||
return new Promise((resolve) => {
|
||||
dialogStore.showDialog({
|
||||
key: 'delete-asset-confirmation',
|
||||
title: t('mediaAsset.deleteAssetTitle'),
|
||||
component: ConfirmationDialogContent,
|
||||
props: {
|
||||
message: t('mediaAsset.deleteAssetDescription'),
|
||||
type: 'delete',
|
||||
itemList: [asset.name],
|
||||
onConfirm: async () => {
|
||||
const success = await deleteAsset(asset, assetType)
|
||||
resolve(success)
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteAsset = async (asset: AssetItem, assetType: string) => {
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
try {
|
||||
if (assetType === 'output') {
|
||||
// For output files, delete from history
|
||||
const promptId =
|
||||
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
|
||||
if (!promptId) {
|
||||
throw new Error('Unable to extract prompt ID from asset')
|
||||
}
|
||||
|
||||
await api.deleteItem('history', promptId)
|
||||
|
||||
// Update history assets in store after deletion
|
||||
await assetsStore.updateHistory()
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.assetDeletedSuccessfully'),
|
||||
life: 2000
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
// For input files, only allow deletion in cloud environment
|
||||
if (!isCloud) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.deletingImportedFilesCloudOnly'),
|
||||
life: 3000
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// In cloud environment, use the assets API to delete
|
||||
await assetService.deleteAsset(asset.id)
|
||||
|
||||
// Update input assets in store after deletion
|
||||
await assetsStore.updateInputs()
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.assetDeletedSuccessfully'),
|
||||
life: 2000
|
||||
})
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete asset:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('mediaAsset.failedToDeleteAsset'),
|
||||
life: 3000
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const playAsset = (assetId: string) => {
|
||||
console.log('Playing asset:', assetId)
|
||||
}
|
||||
|
||||
const copyAssetUrl = (assetId: string) => {
|
||||
console.log('Copy asset URL:', assetId)
|
||||
}
|
||||
const copyJobId = async () => {
|
||||
const asset = mediaContext?.asset.value
|
||||
if (!asset) return
|
||||
|
||||
const copyJobId = (jobId: string) => {
|
||||
console.log('Copy job ID:', jobId)
|
||||
// Get promptId from metadata instead of parsing the ID string
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const promptId = metadata?.promptId
|
||||
|
||||
if (!promptId) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: 'No job ID found for this asset',
|
||||
life: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(promptId)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopied'),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addWorkflow = (assetId: string) => {
|
||||
@@ -45,9 +207,9 @@ export function useMediaAssetActions() {
|
||||
return {
|
||||
selectAsset,
|
||||
downloadAsset,
|
||||
confirmDelete,
|
||||
deleteAsset,
|
||||
playAsset,
|
||||
copyAssetUrl,
|
||||
copyJobId,
|
||||
addWorkflow,
|
||||
openWorkflow,
|
||||
|
||||
41
src/platform/assets/schemas/assetMetadataSchema.ts
Normal file
41
src/platform/assets/schemas/assetMetadataSchema.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
* Metadata for output assets from queue store
|
||||
* Extends Record<string, unknown> for compatibility with AssetItem schema
|
||||
*/
|
||||
export interface OutputAssetMetadata extends Record<string, unknown> {
|
||||
promptId: string
|
||||
nodeId: string | number
|
||||
subfolder: string
|
||||
executionTimeInSeconds?: number
|
||||
format?: string
|
||||
workflow?: unknown
|
||||
outputCount?: number
|
||||
allOutputs?: ResultItemImpl[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if metadata is OutputAssetMetadata
|
||||
*/
|
||||
function isOutputAssetMetadata(
|
||||
metadata: Record<string, unknown> | undefined
|
||||
): metadata is OutputAssetMetadata {
|
||||
if (!metadata) return false
|
||||
return (
|
||||
typeof metadata.promptId === 'string' &&
|
||||
(typeof metadata.nodeId === 'string' || typeof metadata.nodeId === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract output asset metadata
|
||||
*/
|
||||
export function getOutputAssetMetadata(
|
||||
userMetadata: Record<string, unknown> | undefined
|
||||
): OutputAssetMetadata | null {
|
||||
if (isOutputAssetMetadata(userMetadata)) {
|
||||
return userMetadata
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -19,9 +19,7 @@ const zMediaAssetDisplayItemSchema = assetItemSchema.extend({
|
||||
|
||||
// New optional fields
|
||||
duration: z.number().nonnegative().optional(),
|
||||
dimensions: zDimensionsSchema.optional(),
|
||||
jobId: z.string().optional(),
|
||||
isMulti: z.boolean().optional()
|
||||
dimensions: zDimensionsSchema.optional()
|
||||
})
|
||||
|
||||
// Asset context schema
|
||||
|
||||
@@ -195,11 +195,21 @@ function createAssetService() {
|
||||
* Gets assets filtered by a specific tag
|
||||
*
|
||||
* @param tag - The tag to filter by (e.g., 'models')
|
||||
* @param includePublic - Whether to include public assets (default: true)
|
||||
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
|
||||
*/
|
||||
async function getAssetsByTag(tag: string): Promise<AssetItem[]> {
|
||||
async function getAssetsByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true
|
||||
): Promise<AssetItem[]> {
|
||||
const queryParams = new URLSearchParams({
|
||||
include_tags: tag,
|
||||
limit: DEFAULT_LIMIT.toString(),
|
||||
include_public: includePublic ? 'true' : 'false'
|
||||
})
|
||||
|
||||
const data = await handleAssetRequest(
|
||||
`${ASSETS_ENDPOINT}?include_tags=${tag}&limit=${DEFAULT_LIMIT}`,
|
||||
`${ASSETS_ENDPOINT}?${queryParams.toString()}`,
|
||||
`assets for tag ${tag}`
|
||||
)
|
||||
|
||||
@@ -208,13 +218,34 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an asset by ID
|
||||
* Only available in cloud environment
|
||||
*
|
||||
* @param id - The asset ID (UUID)
|
||||
* @returns Promise<void>
|
||||
* @throws Error if deletion fails
|
||||
*/
|
||||
async function deleteAsset(id: string): Promise<void> {
|
||||
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to delete asset ${id}: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
isAssetBrowserEligible,
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag
|
||||
getAssetsByTag,
|
||||
deleteAsset
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
@@ -223,6 +224,13 @@ const uploadFile = async (
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
// Update AssetsStore when uploading to input folder
|
||||
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
|
||||
const assetsStore = useAssetsStore()
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
|
||||
133
src/stores/assetsStore.ts
Normal file
133
src/stores/assetsStore.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
mapTaskOutputToAssetItem
|
||||
} from '@/platform/assets/composables/media/assetMappers'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { TaskItemImpl } from './queueStore'
|
||||
|
||||
/**
|
||||
* Fetch input files from the internal API (OSS version)
|
||||
*/
|
||||
async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
|
||||
const response = await fetch(api.internalURL('/files/input'), {
|
||||
headers: {
|
||||
'Comfy-User': api.user
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch input files')
|
||||
}
|
||||
|
||||
const filenames: string[] = await response.json()
|
||||
return filenames.map((name, index) =>
|
||||
mapInputFileToAssetItem(name, index, 'input')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch input files from cloud service
|
||||
*/
|
||||
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
||||
return await assetService.getAssetsByTag('input', false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert history task items to asset items
|
||||
*/
|
||||
function mapHistoryToAssets(historyItems: any[]): AssetItem[] {
|
||||
const assetItems: AssetItem[] = []
|
||||
|
||||
for (const item of historyItems) {
|
||||
if (!item.outputs || !item.status || item.status?.status_str === 'error') {
|
||||
continue
|
||||
}
|
||||
|
||||
const task = new TaskItemImpl(
|
||||
'History',
|
||||
item.prompt,
|
||||
item.status,
|
||||
item.outputs
|
||||
)
|
||||
|
||||
if (!task.previewOutput) {
|
||||
continue
|
||||
}
|
||||
|
||||
const assetItem = mapTaskOutputToAssetItem(task, task.previewOutput)
|
||||
|
||||
const supportedOutputs = task.flatOutputs.filter((o) => o.supportsPreview)
|
||||
assetItem.user_metadata = {
|
||||
...assetItem.user_metadata,
|
||||
outputCount: supportedOutputs.length,
|
||||
allOutputs: supportedOutputs
|
||||
}
|
||||
|
||||
assetItems.push(assetItem)
|
||||
}
|
||||
|
||||
return assetItems.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
export const useAssetsStore = defineStore('assets', () => {
|
||||
const maxHistoryItems = 200
|
||||
|
||||
const fetchInputFiles = isCloud
|
||||
? fetchInputFilesFromCloud
|
||||
: fetchInputFilesFromAPI
|
||||
|
||||
const {
|
||||
state: inputAssets,
|
||||
isLoading: inputLoading,
|
||||
error: inputError,
|
||||
execute: updateInputs
|
||||
} = useAsyncState(fetchInputFiles, [], {
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
onError: (err) => {
|
||||
console.error('Error fetching input assets:', err)
|
||||
}
|
||||
})
|
||||
|
||||
const fetchHistoryAssets = async (): Promise<AssetItem[]> => {
|
||||
const history = await api.getHistory(maxHistoryItems)
|
||||
return mapHistoryToAssets(history.History)
|
||||
}
|
||||
|
||||
const {
|
||||
state: historyAssets,
|
||||
isLoading: historyLoading,
|
||||
error: historyError,
|
||||
execute: updateHistory
|
||||
} = useAsyncState(fetchHistoryAssets, [], {
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
onError: (err) => {
|
||||
console.error('Error fetching history assets:', err)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// States
|
||||
inputAssets,
|
||||
historyAssets,
|
||||
inputLoading,
|
||||
historyLoading,
|
||||
inputError,
|
||||
historyError,
|
||||
|
||||
// Actions
|
||||
updateInputs,
|
||||
updateHistory
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
|
||||
import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab'
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
|
||||
@@ -45,7 +46,8 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
queue: 'menu.queue',
|
||||
'node-library': 'sideToolbar.nodeLibrary',
|
||||
'model-library': 'sideToolbar.modelLibrary',
|
||||
workflows: 'sideToolbar.workflows'
|
||||
workflows: 'sideToolbar.workflows',
|
||||
assets: 'sideToolbar.assets'
|
||||
}
|
||||
|
||||
const key = menubarLabelKeys[tab.id]
|
||||
@@ -102,6 +104,11 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
* Register the core sidebar tabs.
|
||||
*/
|
||||
const registerCoreSidebarTabs = () => {
|
||||
// Only show AssetsSidebarTab in development mode
|
||||
if (import.meta.env.DEV) {
|
||||
registerSidebarTab(useAssetsSidebarTab())
|
||||
}
|
||||
|
||||
registerSidebarTab(useQueueSidebarTab())
|
||||
registerSidebarTab(useNodeLibrarySidebarTab())
|
||||
registerSidebarTab(useModelLibrarySidebarTab())
|
||||
|
||||
@@ -54,6 +54,7 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
@@ -80,6 +81,7 @@ const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const queueStore = useQueueStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -188,11 +190,17 @@ const init = () => {
|
||||
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
||||
const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
|
||||
queuePendingTaskCountStore.update(e)
|
||||
await queueStore.update()
|
||||
await Promise.all([
|
||||
queueStore.update(),
|
||||
assetsStore.updateHistory() // Update history assets when status changes
|
||||
])
|
||||
}
|
||||
|
||||
const onExecutionSuccess = async () => {
|
||||
await queueStore.update()
|
||||
await Promise.all([
|
||||
queueStore.update(),
|
||||
assetsStore.updateHistory() // Update history assets on execution success
|
||||
])
|
||||
}
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
|
||||
Reference in New Issue
Block a user