mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
feat: Add sort functionality to Media Asset Panel (#6695)
## 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>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<AssetsSidebarTemplate>
|
||||
<template #top>
|
||||
<span v-if="!isInFolderView" class="font-bold">
|
||||
{{ $t('sideToolbar.mediaAssets') }}
|
||||
{{ $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">
|
||||
@@ -39,14 +39,11 @@
|
||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
||||
</TabList>
|
||||
<!-- Search Bar -->
|
||||
<div class="pt-2">
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('sideToolbar.searchAssets')"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<!-- Filter Bar -->
|
||||
<MediaAssetFilterBar
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:sort-by="sortBy"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<!-- Loading state -->
|
||||
@@ -165,12 +162,12 @@ 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 SearchBox from '@/components/input/SearchBox.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'
|
||||
@@ -247,7 +244,8 @@ const baseAssets = computed(() => {
|
||||
})
|
||||
|
||||
// Use media asset filtering composable
|
||||
const { searchQuery, filteredAssets } = useMediaAssetFiltering(baseAssets)
|
||||
const { searchQuery, sortBy, filteredAssets } =
|
||||
useMediaAssetFiltering(baseAssets)
|
||||
|
||||
const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
|
||||
@@ -618,7 +618,11 @@
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"assets": "Assets",
|
||||
"mediaAssets": "Media Assets",
|
||||
"mediaAssets": {
|
||||
"title": "Media Assets",
|
||||
"sortNewestFirst": "Newest first",
|
||||
"sortOldestFirst": "Oldest first"
|
||||
},
|
||||
"backToAssets": "Back to all assets",
|
||||
"searchAssets": "Search assets...",
|
||||
"labels": {
|
||||
|
||||
51
src/platform/assets/components/MediaAssetFilterBar.vue
Normal file
51
src/platform/assets/components/MediaAssetFilterBar.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<SearchBox
|
||||
:model-value="searchQuery"
|
||||
:placeholder="$t('sideToolbar.searchAssets')"
|
||||
size="lg"
|
||||
@update:model-value="handleSearchChange"
|
||||
/>
|
||||
<AssetSortButton
|
||||
v-if="isCloud"
|
||||
v-tooltip.top="{ value: $t('assetBrowser.sortBy') }"
|
||||
size="md"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetSortMenu
|
||||
:sort-by="sortBy"
|
||||
:close="close"
|
||||
@update:sort-by="handleSortChange"
|
||||
/>
|
||||
</template>
|
||||
</AssetSortButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import AssetSortButton from './MediaAssetSortButton.vue'
|
||||
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
|
||||
|
||||
interface MediaAssetSearchBarProps {
|
||||
searchQuery: string
|
||||
sortBy: 'newest' | 'oldest'
|
||||
}
|
||||
|
||||
defineProps<MediaAssetSearchBarProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:sortBy': [value: 'newest' | 'oldest']
|
||||
}>()
|
||||
|
||||
const handleSearchChange = (value: string | undefined) => {
|
||||
emit('update:searchQuery', value ?? '')
|
||||
}
|
||||
|
||||
const handleSortChange = (value: 'newest' | 'oldest') => {
|
||||
emit('update:sortBy', value)
|
||||
}
|
||||
</script>
|
||||
65
src/platform/assets/components/MediaAssetSortButton.vue
Normal file
65
src/platform/assets/components/MediaAssetSortButton.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<IconButton :size="size" :type="type" @click="toggle">
|
||||
<i class="icon-[lucide--arrow-up-down] text-sm" />
|
||||
</IconButton>
|
||||
|
||||
<Popover
|
||||
ref="popover"
|
||||
:append-to="'body'"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface AssetSortButtonProps extends BaseButtonProps {}
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const { size = 'md', type = 'secondary' } = defineProps<AssetSortButtonProps>()
|
||||
|
||||
defineEmits<{
|
||||
menuOpened: []
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: {
|
||||
class: cn('absolute z-50')
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-1 rounded-lg',
|
||||
'bg-base-background text-base-foreground border border-border-default',
|
||||
'shadow-lg'
|
||||
)
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
43
src/platform/assets/components/MediaAssetSortMenu.vue
Normal file
43
src/platform/assets/components/MediaAssetSortMenu.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="$t('sideToolbar.mediaAssets.sortNewestFirst')"
|
||||
@click="handleSortChange('newest')"
|
||||
>
|
||||
<template #icon>
|
||||
<i v-if="sortBy === 'newest'" class="icon-[lucide--check] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="$t('sideToolbar.mediaAssets.sortOldestFirst')"
|
||||
@click="handleSortChange('oldest')"
|
||||
>
|
||||
<template #icon>
|
||||
<i v-if="sortBy === 'oldest'" class="icon-[lucide--check] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
|
||||
const { sortBy, close } = defineProps<{
|
||||
sortBy: 'newest' | 'oldest'
|
||||
close: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sortBy': [value: 'newest' | 'oldest']
|
||||
}>()
|
||||
|
||||
const handleSortChange = (value: 'newest' | 'oldest') => {
|
||||
emit('update:sortBy', value)
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -32,7 +32,8 @@ export function mapTaskOutputToAssetItem(
|
||||
subfolder: output.subfolder,
|
||||
executionTimeInSeconds: taskItem.executionTimeInSeconds,
|
||||
format: output.format,
|
||||
workflow: taskItem.workflow
|
||||
workflow: taskItem.workflow,
|
||||
create_time: taskItem.createTime
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { sortBy as sortByUtil } from 'es-toolkit'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
type SortOption = 'newest' | 'oldest'
|
||||
|
||||
/**
|
||||
* 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)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Media Asset Filtering composable
|
||||
* Manages search, filter, and sort for media assets
|
||||
@@ -12,6 +25,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
||||
const searchQuery = ref('')
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||
const sortBy = ref<SortOption>('newest')
|
||||
|
||||
const fuseOptions = {
|
||||
keys: ['name'],
|
||||
@@ -21,7 +35,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
||||
|
||||
const fuse = computed(() => new Fuse(assets.value, fuseOptions))
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
const searchFiltered = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
return assets.value
|
||||
}
|
||||
@@ -30,8 +44,20 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
||||
return results.map((result) => result.item)
|
||||
})
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
// Sort by create_time (output assets) or created_at (input assets)
|
||||
if (sortBy.value === 'oldest') {
|
||||
// Ascending order (oldest first)
|
||||
return sortByUtil(searchFiltered.value, [getAssetTime])
|
||||
} else {
|
||||
// Descending order (newest first) - negate for descending
|
||||
return sortByUtil(searchFiltered.value, [(asset) => -getAssetTime(asset)])
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
sortBy,
|
||||
filteredAssets
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,18 @@ import type {
|
||||
function mapPromptV2toV1(
|
||||
promptV2: TaskPromptV2,
|
||||
outputs: TaskOutput,
|
||||
syntheticPriority: number
|
||||
syntheticPriority: number,
|
||||
createTime?: number
|
||||
): TaskPrompt {
|
||||
const extraData = {
|
||||
...(promptV2.extra_data ?? {}),
|
||||
...(typeof createTime === 'number' ? { create_time: createTime } : {})
|
||||
}
|
||||
return [
|
||||
syntheticPriority,
|
||||
promptV2.prompt_id,
|
||||
{},
|
||||
promptV2.extra_data,
|
||||
extraData,
|
||||
Object.keys(outputs)
|
||||
]
|
||||
}
|
||||
@@ -55,7 +60,12 @@ export function mapHistoryV2toHistory(
|
||||
|
||||
return {
|
||||
taskType: 'History' as const,
|
||||
prompt: mapPromptV2toV1(prompt, outputs, syntheticPriority),
|
||||
prompt: mapPromptV2toV1(
|
||||
prompt,
|
||||
outputs,
|
||||
syntheticPriority,
|
||||
item.create_time
|
||||
),
|
||||
status,
|
||||
outputs,
|
||||
meta
|
||||
|
||||
@@ -30,7 +30,8 @@ const zRawHistoryItemV2 = z.object({
|
||||
prompt: zTaskPromptV2,
|
||||
status: zStatus.optional(),
|
||||
outputs: zTaskOutput,
|
||||
meta: zTaskMeta.optional()
|
||||
meta: zTaskMeta.optional(),
|
||||
create_time: z.number().int().optional()
|
||||
})
|
||||
|
||||
const zHistoryResponseV2 = z.object({
|
||||
|
||||
@@ -171,11 +171,16 @@ const zExtraPngInfo = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zExtraData = z.object({
|
||||
/** extra_pnginfo can be missing is backend execution gets a validation error. */
|
||||
extra_pnginfo: zExtraPngInfo.optional(),
|
||||
client_id: z.string().optional()
|
||||
})
|
||||
export const zExtraData = z
|
||||
.object({
|
||||
/** extra_pnginfo can be missing is backend execution gets a validation error. */
|
||||
extra_pnginfo: zExtraPngInfo.optional(),
|
||||
client_id: z.string().optional(),
|
||||
// Cloud/Adapters: creation time in milliseconds when available
|
||||
create_time: z.number().int().optional()
|
||||
})
|
||||
// Allow backend/adapters/extensions to add arbitrary metadata
|
||||
.passthrough()
|
||||
const zOutputsToExecute = z.array(zNodeId)
|
||||
|
||||
const zExecutionStartMessage = z.tuple([
|
||||
|
||||
@@ -313,6 +313,22 @@ export class TaskItemImpl {
|
||||
return this.status?.messages || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-provided creation time in milliseconds, when available.
|
||||
*
|
||||
* Sources:
|
||||
* - Queue: 5th tuple element may be a metadata object with { create_time }.
|
||||
* - History (Cloud V2): Adapter injects create_time into prompt[3].extra_data.
|
||||
*/
|
||||
get createTime(): number | undefined {
|
||||
const extra = (this.extraData as any) || {}
|
||||
const fromExtra =
|
||||
typeof extra.create_time === 'number' ? extra.create_time : undefined
|
||||
if (typeof fromExtra === 'number') return fromExtra
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
get interrupted() {
|
||||
return _.some(
|
||||
this.messages,
|
||||
|
||||
Reference in New Issue
Block a user