mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-21 15:24:09 +00:00
## Overview Adds sort functionality to the Media Asset Panel. Users can sort assets by creation time in Cloud environments. ## Key Changes ### 1. Sort Functionality (Cloud Only) - "Newest first" (most recent) - "Oldest first" (oldest) - Sorting based on `create_time` field (output assets) - Sorting based on `created_at` field (input assets) - Sort button is only displayed in Cloud environments ### 2. create_time Field Integration **Related PR**: #6092 Implemented sort functionality using the `create_time` field introduced in PR #6092. Applied the code from that PR directly to the following files: - `src/schemas/apiSchema.ts`: Added `create_time` field to `zExtraData` - `src/stores/queueStore.ts`: Added `createTime` getter to `TaskItemImpl` - `src/platform/remote/comfyui/history/types/historyV2Types.ts`: Added `create_time` to History V2 API response types - `src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.ts`: Pass through `create_time` in V2→V1 adapter - `src/platform/assets/composables/media/assetMappers.ts`: Include `create_time` in asset metadata ### 3. Component Structure Improvements Created new components following existing component styles for consistency: - **`MediaAssetSearchBar.vue`**: Component combining existing SearchBox with sort button - **`AssetSortButton.vue`**: Same structure as `MoreButton.vue` (IconButton + Popover) - **`MediaAssetSortMenu.vue`**: Same style as `MediaAssetMoreMenu.vue` (using IconTextButton) - **`AssetsSidebarTab.vue`**: Refactored to use `MediaAssetSearchBar` ### 4. Utility Usage - Improved sort logic using `es-toolkit`'s `sortBy` - Follows project guidelines (CLAUDE.md) ## Technical Details ### History V2 API's create_time - Cloud backend provides `create_time` (in milliseconds) through History V2 API - Enables accurate sorting by creation time - For input assets, uses existing `created_at` (ISO string) ### Sort Implementation Uses `es-toolkit`'s `sortBy` in `useMediaAssetFiltering` composable: ```typescript // Get timestamp from asset (either create_time or created_at) const getAssetTime = (asset: AssetItem): number => { return ( (asset.user_metadata?.create_time as number) ?? (asset.created_at ? new Date(asset.created_at).getTime() : 0) ) } // Sort by time if (sortBy.value === 'oldest') { return sortByUtil(searchFiltered.value, [getAssetTime]) } else { return sortByUtil(searchFiltered.value, [(asset) => -getAssetTime(asset)]) } ``` ## Testing - ✅ Typecheck passed - ✅ Lint passed - ✅ Format passed 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6695-feat-Add-sort-functionality-to-Media-Asset-Panel-2ab6d73d3650818c818ff3559875d869) by [Unito](https://www.unito.io) Co-authored-by: Claude <noreply@anthropic.com>
432 lines
13 KiB
Vue
432 lines
13 KiB
Vue
<template>
|
|
<AssetsSidebarTemplate>
|
|
<template #top>
|
|
<span v-if="!isInFolderView" class="font-bold">
|
|
{{ $t('sideToolbar.mediaAssets.title') }}
|
|
</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>
|
|
<!-- Filter Bar -->
|
|
<MediaAssetFilterBar
|
|
v-model:search-query="searchQuery"
|
|
v-model:sort-by="sortBy"
|
|
/>
|
|
</template>
|
|
<template #body>
|
|
<!-- Loading state -->
|
|
<div v-if="loading">
|
|
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
|
</div>
|
|
<!-- Empty state -->
|
|
<div v-else-if="!displayAssets.length">
|
|
<NoResultsPlaceholder
|
|
icon="pi pi-info-circle"
|
|
:title="
|
|
$t(
|
|
activeTab === 'input'
|
|
? 'sideToolbar.noImportedFiles'
|
|
: 'sideToolbar.noGeneratedFiles'
|
|
)
|
|
"
|
|
:message="$t('sideToolbar.noFilesFoundMessage')"
|
|
/>
|
|
</div>
|
|
<!-- Content -->
|
|
<div v-else class="relative size-full">
|
|
<VirtualGrid
|
|
:items="mediaAssetsWithKey"
|
|
:grid-style="{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
|
padding: '0 0.5rem',
|
|
gap: '0.5rem'
|
|
}"
|
|
@approach-end="handleApproachEnd"
|
|
>
|
|
<template #item="{ item }">
|
|
<MediaAssetCard
|
|
:asset="item"
|
|
:selected="isSelected(item.id)"
|
|
:show-output-count="shouldShowOutputCount(item)"
|
|
:output-count="getOutputCount(item)"
|
|
:show-delete-button="!isInFolderView"
|
|
@click="handleAssetSelect(item)"
|
|
@zoom="handleZoomClick(item)"
|
|
@output-count-click="enterFolderView(item)"
|
|
@asset-deleted="refreshAssets"
|
|
/>
|
|
</template>
|
|
</VirtualGrid>
|
|
</div>
|
|
</template>
|
|
<template #footer>
|
|
<div
|
|
v-if="hasSelection && activeTab === 'output'"
|
|
class="flex h-18 w-full items-center justify-between px-4"
|
|
>
|
|
<div>
|
|
<TextButton
|
|
v-if="isHoveringSelectionCount"
|
|
:label="$t('mediaAsset.selection.deselectAll')"
|
|
type="transparent"
|
|
@click="handleDeselectAll"
|
|
@mouseleave="isHoveringSelectionCount = false"
|
|
/>
|
|
<span
|
|
v-else
|
|
role="button"
|
|
tabindex="0"
|
|
:aria-label="$t('mediaAsset.selection.deselectAll')"
|
|
class="cursor-pointer px-3 text-sm focus:ring-2 focus:ring-primary focus:outline-none"
|
|
@mouseenter="isHoveringSelectionCount = true"
|
|
@keydown.enter="handleDeselectAll"
|
|
@keydown.space.prevent="handleDeselectAll"
|
|
>
|
|
{{
|
|
$t('mediaAsset.selection.selectedCount', { count: selectedCount })
|
|
}}
|
|
</span>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<IconTextButton
|
|
v-if="!isInFolderView"
|
|
:label="$t('mediaAsset.selection.deleteSelected')"
|
|
type="secondary"
|
|
icon-position="right"
|
|
@click="handleDeleteSelected"
|
|
>
|
|
<template #icon>
|
|
<i class="icon-[lucide--trash-2] size-4" />
|
|
</template>
|
|
</IconTextButton>
|
|
<IconTextButton
|
|
:label="$t('mediaAsset.selection.downloadSelected')"
|
|
type="secondary"
|
|
icon-position="right"
|
|
@click="handleDownloadSelected"
|
|
>
|
|
<template #icon>
|
|
<i class="icon-[lucide--download] size-4" />
|
|
</template>
|
|
</IconTextButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</AssetsSidebarTemplate>
|
|
<ResultGallery
|
|
v-model:active-index="galleryActiveIndex"
|
|
:all-gallery-items="galleryItems"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useDebounceFn } from '@vueuse/core'
|
|
import ProgressSpinner from 'primevue/progressspinner'
|
|
import { useToast } from 'primevue/usetoast'
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
|
import TextButton from '@/components/button/TextButton.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 MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
|
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
|
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
|
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
|
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
|
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'>('output')
|
|
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')
|
|
|
|
// Asset selection
|
|
const {
|
|
isSelected,
|
|
handleAssetClick,
|
|
hasSelection,
|
|
selectedCount,
|
|
clearSelection,
|
|
getSelectedAssets,
|
|
activate: activateSelection,
|
|
deactivate: deactivateSelection
|
|
} = useAssetSelection()
|
|
|
|
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
|
|
|
|
// Hover state for selection count
|
|
const isHoveringSelectionCount = ref(false)
|
|
|
|
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[]>([])
|
|
|
|
// Base assets before search filtering
|
|
const baseAssets = computed(() => {
|
|
if (isInFolderView.value) {
|
|
return folderAssets.value
|
|
}
|
|
return mediaAssets.value
|
|
})
|
|
|
|
// Use media asset filtering composable
|
|
const { searchQuery, sortBy, filteredAssets } =
|
|
useMediaAssetFiltering(baseAssets)
|
|
|
|
const displayAssets = computed(() => {
|
|
return filteredAssets.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,
|
|
() => {
|
|
clearSelection()
|
|
// Clear search when switching tabs
|
|
searchQuery.value = ''
|
|
// Reset pagination state when tab changes
|
|
void refreshAssets()
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
const handleAssetSelect = (asset: AssetItem) => {
|
|
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
|
handleAssetClick(asset, index, displayAssets.value)
|
|
}
|
|
|
|
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 = []
|
|
searchQuery.value = ''
|
|
clearSelection()
|
|
}
|
|
|
|
onMounted(() => {
|
|
activateSelection()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
deactivateSelection()
|
|
})
|
|
|
|
const handleDeselectAll = () => {
|
|
clearSelection()
|
|
isHoveringSelectionCount.value = false
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleDownloadSelected = () => {
|
|
const selectedAssets = getSelectedAssets(displayAssets.value)
|
|
downloadMultipleAssets(selectedAssets)
|
|
clearSelection()
|
|
}
|
|
|
|
const handleDeleteSelected = async () => {
|
|
const selectedAssets = getSelectedAssets(displayAssets.value)
|
|
await deleteMultipleAssets(selectedAssets)
|
|
clearSelection()
|
|
}
|
|
|
|
const handleApproachEnd = useDebounceFn(async () => {
|
|
if (
|
|
activeTab.value === 'output' &&
|
|
!isInFolderView.value &&
|
|
outputAssets.hasMore.value &&
|
|
!outputAssets.isLoadingMore.value
|
|
) {
|
|
await outputAssets.loadMore()
|
|
}
|
|
}, 300)
|
|
</script>
|