Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/sidebar/tabs/AssetsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<MediaAssetFilterBar
v-model:search-query="searchQuery"
v-model:sort-by="sortBy"
v-model:media-type-filters="mediaTypeFilters"
:show-generation-time-sort="activeTab === 'output'"
/>
</template>
Expand Down Expand Up @@ -255,7 +256,7 @@ const baseAssets = computed(() => {
})

// Use media asset filtering composable
const { searchQuery, sortBy, filteredAssets } =
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(baseAssets)

const displayAssets = computed(() => {
Expand Down
7 changes: 6 additions & 1 deletion src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,11 @@
"sortNewestFirst": "Newest first",
"sortOldestFirst": "Oldest first",
"sortLongestFirst": "Generation time (longest first)",
"sortFastestFirst": "Generation time (fastest first)"
"sortFastestFirst": "Generation time (fastest first)",
"filterImage": "Image",
"filterVideo": "Video",
"filterAudio": "Audio",
"filter3D": "3D"
},
"backToAssets": "Back to all assets",
"searchAssets": "Search assets...",
Expand Down Expand Up @@ -2009,6 +2013,7 @@
"unknown": "Unknown",
"fileFormats": "File formats",
"baseModels": "Base models",
"filterBy": "Filter by",
"sortBy": "Sort by",
"sortAZ": "A-Z",
"sortZA": "Z-A",
Expand Down
21 changes: 21 additions & 0 deletions src/platform/assets/components/MediaAssetFilterBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@
size="lg"
@update:model-value="handleSearchChange"
/>
<MediaAssetFilterButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
size="md"
>
<template #default="{ close }">
<MediaAssetFilterMenu
:media-type-filters="mediaTypeFilters"
:close="close"
@update:media-type-filters="handleMediaTypeFiltersChange"
/>
</template>
</MediaAssetFilterButton>
<AssetSortButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.sortBy') }"
Expand All @@ -27,18 +40,22 @@
import SearchBox from '@/components/input/SearchBox.vue'
import { isCloud } from '@/platform/distribution/types'

import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
import AssetSortButton from './MediaAssetSortButton.vue'
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'

const { showGenerationTimeSort = false } = defineProps<{
searchQuery: string
sortBy: 'newest' | 'oldest' | 'longest' | 'fastest'
showGenerationTimeSort?: boolean
mediaTypeFilters: string[]
}>()

const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:sortBy': [value: 'newest' | 'oldest' | 'longest' | 'fastest']
'update:mediaTypeFilters': [value: string[]]
}>()

const handleSearchChange = (value: string | undefined) => {
Expand All @@ -50,4 +67,8 @@ const handleSortChange = (
) => {
emit('update:sortBy', value)
}

const handleMediaTypeFiltersChange = (value: string[]) => {
emit('update:mediaTypeFilters', value)
}
</script>
66 changes: 66 additions & 0 deletions src/platform/assets/components/MediaAssetFilterButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<template>
<div class="relative inline-flex items-center">
<IconButton :size :type @click="toggle">
<i class="icon-[lucide--list-filter] text-sm" />
</IconButton>

<Popover
ref="popover"
append-to="#vue-app"
auto-z-index
:base-z-index="1000"
dismissable
close-on-escape
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 AssetFilterButtonProps extends BaseButtonProps {}

const popover = ref<InstanceType<typeof Popover>>()

const { size = 'md', type = 'secondary' } =
defineProps<AssetFilterButtonProps>()

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>
72 changes: 72 additions & 0 deletions src/platform/assets/components/MediaAssetFilterMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!--
TODO: Extract checkbox pattern into reusable Checkbox component
- Create src/components/input/Checkbox.vue with:
- Hidden native <input type="checkbox"> for accessibility
- Custom visual styling matching this implementation
- Semantic tokens (--primary-background, --input-surface, etc.)
- Use this Checkbox component in:
- MediaAssetFilterMenu.vue (this file)
- MultiSelect.vue option template
- SingleSelect.vue if needed
- Benefits: Consistent checkbox UI, better maintainability, reusable design system component
-->
<template>
<div class="flex flex-col gap-0 p-0 m-0">
<div
v-for="filter in filters"
:key="filter.type"
class="flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-secondary-background-hover"
tabindex="0"
role="checkbox"
:aria-checked="mediaTypeFilters.includes(filter.type)"
@click="toggleMediaType(filter.type)"
@keydown.enter.prevent="toggleMediaType(filter.type)"
@keydown.space.prevent="toggleMediaType(filter.type)"
>
<div
class="flex h-4 w-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
mediaTypeFilters.includes(filter.type)
? 'bg-primary-background border-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="mediaTypeFilters.includes(filter.type)"
class="icon-[lucide--check] text-xs font-bold text-white"
/>
</div>
<span class="text-sm">{{ $t(filter.label) }}</span>
</div>
</div>
</template>

<script setup lang="ts">
const { mediaTypeFilters } = defineProps<{
mediaTypeFilters: string[]
close: () => void
}>()

const emit = defineEmits<{
'update:mediaTypeFilters': [value: string[]]
}>()

const filters = [
{ type: 'image', label: 'sideToolbar.mediaAssets.filterImage' },
{ type: 'video', label: 'sideToolbar.mediaAssets.filterVideo' },
{ type: 'audio', label: 'sideToolbar.mediaAssets.filterAudio' },
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' }
]

const toggleMediaType = (type: string) => {
const isCurrentlySelected = mediaTypeFilters.includes(type)
if (isCurrentlySelected) {
emit(
'update:mediaTypeFilters',
mediaTypeFilters.filter((t) => t !== type)
)
} else {
emit('update:mediaTypeFilters', [...mediaTypeFilters, type])
}
}
</script>
27 changes: 21 additions & 6 deletions src/platform/assets/composables/useMediaAssetFiltering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
import type { Ref } from 'vue'

import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'

type SortOption = 'newest' | 'oldest' | 'longest' | 'fastest'

Expand Down Expand Up @@ -33,6 +34,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
const searchQuery = ref('')
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const sortBy = ref<SortOption>('newest')
const mediaTypeFilters = ref<string[]>([])

const fuseOptions = {
keys: ['name'],
Expand All @@ -51,32 +53,45 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
return results.map((result) => result.item)
})

const typeFiltered = computed(() => {
// Apply media type filter
if (mediaTypeFilters.value.length === 0) {
return searchFiltered.value
}

return searchFiltered.value.filter((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
// Convert '3D' to '3d' for comparison
const normalizedType = mediaType.toLowerCase()
return mediaTypeFilters.value.includes(normalizedType)
})
})

const filteredAssets = computed(() => {
// Sort by create_time (output assets) or created_at (input assets)
switch (sortBy.value) {
case 'oldest':
// Ascending order (oldest first)
return sortByUtil(searchFiltered.value, [getAssetTime])
return sortByUtil(typeFiltered.value, [getAssetTime])
case 'longest':
// Descending order (longest execution time first)
return sortByUtil(searchFiltered.value, [
return sortByUtil(typeFiltered.value, [
(asset) => -getAssetExecutionTime(asset)
])
case 'fastest':
// Ascending order (fastest execution time first)
return sortByUtil(searchFiltered.value, [getAssetExecutionTime])
return sortByUtil(typeFiltered.value, [getAssetExecutionTime])
case 'newest':
default:
// Descending order (newest first) - negate for descending
return sortByUtil(searchFiltered.value, [
(asset) => -getAssetTime(asset)
])
return sortByUtil(typeFiltered.value, [(asset) => -getAssetTime(asset)])
}
})

return {
searchQuery,
sortBy,
mediaTypeFilters,
filteredAssets
}
}
3 changes: 2 additions & 1 deletion src/platform/assets/schemas/assetMetadataSchema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'

/**
Expand All @@ -10,7 +11,7 @@ export interface OutputAssetMetadata extends Record<string, unknown> {
subfolder: string
executionTimeInSeconds?: number
format?: string
workflow?: unknown
workflow?: ComfyWorkflowJSON
outputCount?: number
allOutputs?: ResultItemImpl[]
}
Expand Down