Skip to content

Commit f80fc4c

Browse files
viva-jinyiclaude
andauthored
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>
1 parent 8b8f353 commit f80fc4c

File tree

11 files changed

+243
-23
lines changed

11 files changed

+243
-23
lines changed

src/components/sidebar/tabs/AssetsSidebarTab.vue

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<AssetsSidebarTemplate>
33
<template #top>
44
<span v-if="!isInFolderView" class="font-bold">
5-
{{ $t('sideToolbar.mediaAssets') }}
5+
{{ $t('sideToolbar.mediaAssets.title') }}
66
</span>
77
<div v-else class="flex w-full items-center justify-between gap-2">
88
<div class="flex items-center gap-2">
@@ -39,14 +39,11 @@
3939
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
4040
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
4141
</TabList>
42-
<!-- Search Bar -->
43-
<div class="pt-2">
44-
<SearchBox
45-
v-model="searchQuery"
46-
:placeholder="$t('sideToolbar.searchAssets')"
47-
size="lg"
48-
/>
49-
</div>
42+
<!-- Filter Bar -->
43+
<MediaAssetFilterBar
44+
v-model:search-query="searchQuery"
45+
v-model:sort-by="sortBy"
46+
/>
5047
</template>
5148
<template #body>
5249
<!-- Loading state -->
@@ -165,12 +162,12 @@ import IconTextButton from '@/components/button/IconTextButton.vue'
165162
import TextButton from '@/components/button/TextButton.vue'
166163
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
167164
import VirtualGrid from '@/components/common/VirtualGrid.vue'
168-
import SearchBox from '@/components/input/SearchBox.vue'
169165
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
170166
import Tab from '@/components/tab/Tab.vue'
171167
import TabList from '@/components/tab/TabList.vue'
172168
import { t } from '@/i18n'
173169
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
170+
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
174171
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
175172
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
176173
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
@@ -247,7 +244,8 @@ const baseAssets = computed(() => {
247244
})
248245
249246
// Use media asset filtering composable
250-
const { searchQuery, filteredAssets } = useMediaAssetFiltering(baseAssets)
247+
const { searchQuery, sortBy, filteredAssets } =
248+
useMediaAssetFiltering(baseAssets)
251249
252250
const displayAssets = computed(() => {
253251
return filteredAssets.value

src/locales/en/main.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,11 @@
618618
"workflows": "Workflows",
619619
"templates": "Templates",
620620
"assets": "Assets",
621-
"mediaAssets": "Media Assets",
621+
"mediaAssets": {
622+
"title": "Media Assets",
623+
"sortNewestFirst": "Newest first",
624+
"sortOldestFirst": "Oldest first"
625+
},
622626
"backToAssets": "Back to all assets",
623627
"searchAssets": "Search assets...",
624628
"labels": {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<template>
2+
<div class="flex gap-3 pt-2">
3+
<SearchBox
4+
:model-value="searchQuery"
5+
:placeholder="$t('sideToolbar.searchAssets')"
6+
size="lg"
7+
@update:model-value="handleSearchChange"
8+
/>
9+
<AssetSortButton
10+
v-if="isCloud"
11+
v-tooltip.top="{ value: $t('assetBrowser.sortBy') }"
12+
size="md"
13+
>
14+
<template #default="{ close }">
15+
<MediaAssetSortMenu
16+
:sort-by="sortBy"
17+
:close="close"
18+
@update:sort-by="handleSortChange"
19+
/>
20+
</template>
21+
</AssetSortButton>
22+
</div>
23+
</template>
24+
25+
<script setup lang="ts">
26+
import SearchBox from '@/components/input/SearchBox.vue'
27+
import { isCloud } from '@/platform/distribution/types'
28+
29+
import AssetSortButton from './MediaAssetSortButton.vue'
30+
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
31+
32+
interface MediaAssetSearchBarProps {
33+
searchQuery: string
34+
sortBy: 'newest' | 'oldest'
35+
}
36+
37+
defineProps<MediaAssetSearchBarProps>()
38+
39+
const emit = defineEmits<{
40+
'update:searchQuery': [value: string]
41+
'update:sortBy': [value: 'newest' | 'oldest']
42+
}>()
43+
44+
const handleSearchChange = (value: string | undefined) => {
45+
emit('update:searchQuery', value ?? '')
46+
}
47+
48+
const handleSortChange = (value: 'newest' | 'oldest') => {
49+
emit('update:sortBy', value)
50+
}
51+
</script>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<template>
2+
<div class="relative inline-flex items-center">
3+
<IconButton :size="size" :type="type" @click="toggle">
4+
<i class="icon-[lucide--arrow-up-down] text-sm" />
5+
</IconButton>
6+
7+
<Popover
8+
ref="popover"
9+
:append-to="'body'"
10+
:auto-z-index="true"
11+
:base-z-index="1000"
12+
:dismissable="true"
13+
:close-on-escape="true"
14+
unstyled
15+
:pt="pt"
16+
@show="$emit('menuOpened')"
17+
@hide="$emit('menuClosed')"
18+
>
19+
<div class="flex min-w-40 flex-col gap-2 p-2">
20+
<slot :close="hide" />
21+
</div>
22+
</Popover>
23+
</div>
24+
</template>
25+
26+
<script setup lang="ts">
27+
import Popover from 'primevue/popover'
28+
import { computed, ref } from 'vue'
29+
30+
import IconButton from '@/components/button/IconButton.vue'
31+
import type { BaseButtonProps } from '@/types/buttonTypes'
32+
import { cn } from '@/utils/tailwindUtil'
33+
34+
interface AssetSortButtonProps extends BaseButtonProps {}
35+
36+
const popover = ref<InstanceType<typeof Popover>>()
37+
38+
const { size = 'md', type = 'secondary' } = defineProps<AssetSortButtonProps>()
39+
40+
defineEmits<{
41+
menuOpened: []
42+
menuClosed: []
43+
}>()
44+
45+
const toggle = (event: Event) => {
46+
popover.value?.toggle(event)
47+
}
48+
49+
const hide = () => {
50+
popover.value?.hide()
51+
}
52+
53+
const pt = computed(() => ({
54+
root: {
55+
class: cn('absolute z-50')
56+
},
57+
content: {
58+
class: cn(
59+
'mt-1 rounded-lg',
60+
'bg-base-background text-base-foreground border border-border-default',
61+
'shadow-lg'
62+
)
63+
}
64+
}))
65+
</script>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<template>
2+
<div class="flex flex-col">
3+
<IconTextButton
4+
type="transparent"
5+
icon-position="right"
6+
:label="$t('sideToolbar.mediaAssets.sortNewestFirst')"
7+
@click="handleSortChange('newest')"
8+
>
9+
<template #icon>
10+
<i v-if="sortBy === 'newest'" class="icon-[lucide--check] size-4" />
11+
</template>
12+
</IconTextButton>
13+
14+
<IconTextButton
15+
type="transparent"
16+
icon-position="right"
17+
:label="$t('sideToolbar.mediaAssets.sortOldestFirst')"
18+
@click="handleSortChange('oldest')"
19+
>
20+
<template #icon>
21+
<i v-if="sortBy === 'oldest'" class="icon-[lucide--check] size-4" />
22+
</template>
23+
</IconTextButton>
24+
</div>
25+
</template>
26+
27+
<script setup lang="ts">
28+
import IconTextButton from '@/components/button/IconTextButton.vue'
29+
30+
const { sortBy, close } = defineProps<{
31+
sortBy: 'newest' | 'oldest'
32+
close: () => void
33+
}>()
34+
35+
const emit = defineEmits<{
36+
'update:sortBy': [value: 'newest' | 'oldest']
37+
}>()
38+
39+
const handleSortChange = (value: 'newest' | 'oldest') => {
40+
emit('update:sortBy', value)
41+
close()
42+
}
43+
</script>

src/platform/assets/composables/media/assetMappers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export function mapTaskOutputToAssetItem(
3232
subfolder: output.subfolder,
3333
executionTimeInSeconds: taskItem.executionTimeInSeconds,
3434
format: output.format,
35-
workflow: taskItem.workflow
35+
workflow: taskItem.workflow,
36+
create_time: taskItem.createTime
3637
}
3738

3839
return {

src/platform/assets/composables/useMediaAssetFiltering.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
import { refDebounced } from '@vueuse/core'
2+
import { sortBy as sortByUtil } from 'es-toolkit'
23
import Fuse from 'fuse.js'
34
import { computed, ref } from 'vue'
45
import type { Ref } from 'vue'
56

67
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
78

9+
type SortOption = 'newest' | 'oldest'
10+
11+
/**
12+
* Get timestamp from asset (either create_time or created_at)
13+
*/
14+
const getAssetTime = (asset: AssetItem): number => {
15+
return (
16+
(asset.user_metadata?.create_time as number) ??
17+
(asset.created_at ? new Date(asset.created_at).getTime() : 0)
18+
)
19+
}
20+
821
/**
922
* Media Asset Filtering composable
1023
* Manages search, filter, and sort for media assets
1124
*/
1225
export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
1326
const searchQuery = ref('')
1427
const debouncedSearchQuery = refDebounced(searchQuery, 50)
28+
const sortBy = ref<SortOption>('newest')
1529

1630
const fuseOptions = {
1731
keys: ['name'],
@@ -21,7 +35,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
2135

2236
const fuse = computed(() => new Fuse(assets.value, fuseOptions))
2337

24-
const filteredAssets = computed(() => {
38+
const searchFiltered = computed(() => {
2539
if (!debouncedSearchQuery.value.trim()) {
2640
return assets.value
2741
}
@@ -30,8 +44,20 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
3044
return results.map((result) => result.item)
3145
})
3246

47+
const filteredAssets = computed(() => {
48+
// Sort by create_time (output assets) or created_at (input assets)
49+
if (sortBy.value === 'oldest') {
50+
// Ascending order (oldest first)
51+
return sortByUtil(searchFiltered.value, [getAssetTime])
52+
} else {
53+
// Descending order (newest first) - negate for descending
54+
return sortByUtil(searchFiltered.value, [(asset) => -getAssetTime(asset)])
55+
}
56+
})
57+
3358
return {
3459
searchQuery,
60+
sortBy,
3561
filteredAssets
3662
}
3763
}

src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@ import type {
1313
function mapPromptV2toV1(
1414
promptV2: TaskPromptV2,
1515
outputs: TaskOutput,
16-
syntheticPriority: number
16+
syntheticPriority: number,
17+
createTime?: number
1718
): TaskPrompt {
19+
const extraData = {
20+
...(promptV2.extra_data ?? {}),
21+
...(typeof createTime === 'number' ? { create_time: createTime } : {})
22+
}
1823
return [
1924
syntheticPriority,
2025
promptV2.prompt_id,
2126
{},
22-
promptV2.extra_data,
27+
extraData,
2328
Object.keys(outputs)
2429
]
2530
}
@@ -55,7 +60,12 @@ export function mapHistoryV2toHistory(
5560

5661
return {
5762
taskType: 'History' as const,
58-
prompt: mapPromptV2toV1(prompt, outputs, syntheticPriority),
63+
prompt: mapPromptV2toV1(
64+
prompt,
65+
outputs,
66+
syntheticPriority,
67+
item.create_time
68+
),
5969
status,
6070
outputs,
6171
meta

src/platform/remote/comfyui/history/types/historyV2Types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ const zRawHistoryItemV2 = z.object({
3030
prompt: zTaskPromptV2,
3131
status: zStatus.optional(),
3232
outputs: zTaskOutput,
33-
meta: zTaskMeta.optional()
33+
meta: zTaskMeta.optional(),
34+
create_time: z.number().int().optional()
3435
})
3536

3637
const zHistoryResponseV2 = z.object({

src/schemas/apiSchema.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,16 @@ const zExtraPngInfo = z
171171
})
172172
.passthrough()
173173

174-
export const zExtraData = z.object({
175-
/** extra_pnginfo can be missing is backend execution gets a validation error. */
176-
extra_pnginfo: zExtraPngInfo.optional(),
177-
client_id: z.string().optional()
178-
})
174+
export const zExtraData = z
175+
.object({
176+
/** extra_pnginfo can be missing is backend execution gets a validation error. */
177+
extra_pnginfo: zExtraPngInfo.optional(),
178+
client_id: z.string().optional(),
179+
// Cloud/Adapters: creation time in milliseconds when available
180+
create_time: z.number().int().optional()
181+
})
182+
// Allow backend/adapters/extensions to add arbitrary metadata
183+
.passthrough()
179184
const zOutputsToExecute = z.array(zNodeId)
180185

181186
const zExecutionStartMessage = z.tuple([

0 commit comments

Comments
 (0)