mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 22:59:14 +00:00
- Use existing formatDuration util from shared utils instead of custom implementation - Extract execution time formatting to computed property for cleaner template - Remove 'any' type assertion in MediaAssetActions, use proper types
292 lines
8.6 KiB
Vue
292 lines
8.6 KiB
Vue
<template>
|
|
<SidebarTabTemplate
|
|
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets')"
|
|
>
|
|
<template v-if="isInFolderView" #tool-buttons>
|
|
<div class="flex w-full items-center gap-2">
|
|
<span class="font-medium"
|
|
>Job ID: {{ folderPromptId?.substring(0, 8) }}</span
|
|
>
|
|
<button
|
|
class="rounded p-1 transition-colors hover:bg-neutral-100 dark-theme:hover:bg-neutral-700"
|
|
:title="$t('g.copy')"
|
|
@click="copyJobId"
|
|
>
|
|
<i class="icon-[lucide--copy] size-4" />
|
|
</button>
|
|
<span class="ml-auto text-sm text-neutral-500">
|
|
{{ formattedExecutionTime }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<template #header>
|
|
<!-- Job Detail View Header -->
|
|
<div
|
|
v-if="isInFolderView"
|
|
class="border-b border-neutral-300 px-4 pt-2 pb-3 dark-theme:border-neutral-700"
|
|
>
|
|
<button
|
|
class="flex items-center gap-2 rounded bg-neutral-100 px-3 py-1.5 text-sm transition-colors hover:bg-neutral-200 dark-theme:bg-neutral-700 dark-theme:hover:bg-neutral-600"
|
|
@click="exitFolderView"
|
|
>
|
|
<i class="icon-[lucide--arrow-left] size-4" />
|
|
<span>Back to all assets</span>
|
|
</button>
|
|
</div>
|
|
<!-- Normal Tab View -->
|
|
<Tabs v-model:value="activeTab" class="w-full">
|
|
<TabList class="border-b border-neutral-300">
|
|
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
|
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
|
</TabList>
|
|
</Tabs>
|
|
</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="
|
|
activeTab === 'output' &&
|
|
!isInFolderView &&
|
|
(item.user_metadata?.outputCount as number) > 1
|
|
"
|
|
:output-count="(item.user_metadata?.outputCount as number) || 0"
|
|
@click="handleAssetSelect(item)"
|
|
@zoom="handleZoomClick(item)"
|
|
@output-count-click="enterFolderView(item)"
|
|
/>
|
|
</template>
|
|
</VirtualGrid>
|
|
<div v-else-if="loading">
|
|
<ProgressSpinner
|
|
style="width: 50px; left: 50%; transform: translateX(-50%)"
|
|
/>
|
|
</div>
|
|
<div v-else>
|
|
<NoResultsPlaceholder
|
|
icon="pi pi-info-circle"
|
|
:title="
|
|
$t(
|
|
activeTab === 'input'
|
|
? 'sideToolbar.noImportedFiles'
|
|
: 'sideToolbar.noGeneratedFiles'
|
|
)
|
|
"
|
|
:message="$t('sideToolbar.noFilesFoundMessage')"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</SidebarTabTemplate>
|
|
<ResultGallery
|
|
v-model:active-index="galleryActiveIndex"
|
|
:all-gallery-items="galleryItems"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import ProgressSpinner from 'primevue/progressspinner'
|
|
import Tab from 'primevue/tab'
|
|
import TabList from 'primevue/tablist'
|
|
import Tabs from 'primevue/tabs'
|
|
import { useToast } from 'primevue/usetoast'
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
|
|
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
|
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
|
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
|
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
|
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
|
import { useMediaAssets } from '@/platform/assets/composables/useMediaAssets'
|
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|
import { ResultItemImpl } from '@/stores/queueStore'
|
|
import {
|
|
formatDuration,
|
|
getMediaTypeFromFilenamePlural
|
|
} from '@/utils/formatUtil'
|
|
|
|
const activeTab = ref<'input' | 'output'>('input')
|
|
const mediaAssets = ref<AssetItem[]>([])
|
|
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 formattedExecutionTime = computed(() => {
|
|
if (!folderExecutionTime.value) return ''
|
|
return formatDuration(folderExecutionTime.value * 1000)
|
|
})
|
|
|
|
const toast = useToast()
|
|
|
|
// Use unified media assets implementation that handles cloud/internal automatically
|
|
const { loading, error, fetchMediaList } = useMediaAssets()
|
|
|
|
const galleryActiveIndex = ref(-1)
|
|
const galleryItems = computed(() => {
|
|
// Convert AssetItems to ResultItemImpl format for gallery
|
|
// Use displayAssets instead of mediaAssets to show correct items based on view mode
|
|
return displayAssets.value.map((asset) => {
|
|
const resultItem = new ResultItemImpl({
|
|
filename: asset.name,
|
|
subfolder: '',
|
|
type: 'output',
|
|
nodeId: '0',
|
|
mediaType: getMediaTypeFromFilenamePlural(asset.name)
|
|
})
|
|
|
|
// Override the url getter to use asset.preview_url
|
|
Object.defineProperty(resultItem, 'url', {
|
|
get() {
|
|
return asset.preview_url || ''
|
|
},
|
|
configurable: true
|
|
})
|
|
|
|
return resultItem
|
|
})
|
|
})
|
|
|
|
// Group assets by promptId for output tab
|
|
const groupedAssets = computed(() => {
|
|
if (activeTab.value !== 'output' || isInFolderView.value) {
|
|
return null
|
|
}
|
|
|
|
const groups = new Map<string, AssetItem[]>()
|
|
|
|
mediaAssets.value.forEach((asset) => {
|
|
const promptId = asset.user_metadata?.promptId as string
|
|
if (promptId) {
|
|
if (!groups.has(promptId)) {
|
|
groups.set(promptId, [])
|
|
}
|
|
groups.get(promptId)!.push(asset)
|
|
}
|
|
})
|
|
|
|
return groups
|
|
})
|
|
|
|
// Get display assets based on view mode
|
|
const displayAssets = computed(() => {
|
|
if (isInFolderView.value && folderPromptId.value) {
|
|
// Show all assets from the selected prompt
|
|
return mediaAssets.value.filter(
|
|
(asset) => asset.user_metadata?.promptId === folderPromptId.value
|
|
)
|
|
}
|
|
|
|
if (activeTab.value === 'output' && groupedAssets.value) {
|
|
// Show only the first asset from each prompt group
|
|
const firstAssets: AssetItem[] = []
|
|
groupedAssets.value.forEach((assets) => {
|
|
if (assets.length > 0) {
|
|
// Add output count to the first asset
|
|
const firstAsset = { ...assets[0] }
|
|
firstAsset.user_metadata = {
|
|
...firstAsset.user_metadata,
|
|
outputCount: assets.length
|
|
}
|
|
firstAssets.push(firstAsset)
|
|
}
|
|
})
|
|
return firstAssets
|
|
}
|
|
|
|
return mediaAssets.value
|
|
})
|
|
|
|
// Add key property for VirtualGrid
|
|
const mediaAssetsWithKey = computed(() => {
|
|
return displayAssets.value.map((asset) => ({
|
|
...asset,
|
|
key: asset.id
|
|
}))
|
|
})
|
|
|
|
const refreshAssets = async () => {
|
|
const files = await fetchMediaList(activeTab.value)
|
|
mediaAssets.value = files
|
|
if (error.value) {
|
|
console.error('Failed to refresh assets:', error.value)
|
|
}
|
|
}
|
|
|
|
watch(activeTab, () => {
|
|
void refreshAssets()
|
|
})
|
|
|
|
onMounted(() => {
|
|
void refreshAssets()
|
|
})
|
|
|
|
const handleAssetSelect = (asset: AssetItem) => {
|
|
// Toggle selection
|
|
if (selectedAsset.value?.id === asset.id) {
|
|
selectedAsset.value = null
|
|
} else {
|
|
selectedAsset.value = asset
|
|
}
|
|
}
|
|
|
|
const handleZoomClick = (asset: AssetItem) => {
|
|
// Find the index of the clicked asset
|
|
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
|
if (index !== -1) {
|
|
galleryActiveIndex.value = index
|
|
}
|
|
}
|
|
|
|
const enterFolderView = (asset: AssetItem) => {
|
|
const promptId = asset.user_metadata?.promptId as string
|
|
if (promptId) {
|
|
folderPromptId.value = promptId
|
|
// Get execution time from the first asset of this prompt
|
|
const promptAssets = mediaAssets.value.filter(
|
|
(a) => a.user_metadata?.promptId === promptId
|
|
)
|
|
if (promptAssets.length > 0) {
|
|
folderExecutionTime.value = promptAssets[0].user_metadata
|
|
?.executionTimeInSeconds as number
|
|
}
|
|
}
|
|
}
|
|
|
|
const exitFolderView = () => {
|
|
folderPromptId.value = null
|
|
folderExecutionTime.value = undefined
|
|
}
|
|
|
|
const copyJobId = async () => {
|
|
if (folderPromptId.value) {
|
|
try {
|
|
await navigator.clipboard.writeText(folderPromptId.value)
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'Copied',
|
|
detail: 'Job ID copied to clipboard',
|
|
life: 2000
|
|
})
|
|
} catch (error) {
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Error',
|
|
detail: 'Failed to copy Job ID',
|
|
life: 3000
|
|
})
|
|
}
|
|
}
|
|
}
|
|
</script>
|