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:
Jin Yi
2025-11-14 13:56:04 +09:00
committed by GitHub
parent 8b8f3538bf
commit f80fc4cf9a
11 changed files with 243 additions and 23 deletions

View File

@@ -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

View File

@@ -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": {

View 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>

View 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>

View 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>

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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({

View File

@@ -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([

View File

@@ -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,