[feat] Add search functionality to Media Asset Panel (#6691)

## Summary

Add search functionality to the Media Asset Panel, allowing users to
search for assets by filename.

## Changes

### 1. Search Feature
- Added SearchBox component to AssetsSidebarTab header
- Implemented fuzzy search using Fuse.js
- Works in both Imported and Generated tabs
- Search also available in folder view

### 2. New Composable: `useMediaAssetFiltering`
- Location: `src/platform/assets/composables/useMediaAssetFiltering.ts`
- Encapsulates search logic in a reusable composable
- Extensible structure for future filter and sort features
- Debounced search (50ms)

### 3. UX Improvements
- Search query automatically clears when switching tabs
- Search query automatically clears when exiting folder view

## Testing

-  TypeScript type check passed
-  ESLint/Oxlint passed
-  Lint-staged pre-commit hooks passed

## Modified Files

- `src/components/sidebar/tabs/AssetsSidebarTab.vue` - Added SearchBox
- `src/platform/assets/composables/useMediaAssetFiltering.ts` - New file
- `src/locales/en/main.json` - Added i18n key
(`sideToolbar.searchAssets`)

## Future Plans

- Add filter functionality (file type, date, etc.)
- Add sort functionality
- Switch to server-side search for OSS/Cloud (after Asset API and Job
API release)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6691-feat-Add-search-functionality-to-Media-Asset-Panel-2ab6d73d3650817b8b95f3450179524f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jin Yi
2025-11-14 12:00:46 +09:00
committed by GitHub
parent bd6825a274
commit 1feee48284
4 changed files with 62 additions and 3 deletions

View File

@@ -10,7 +10,7 @@
> >
<slot name="top" /> <slot name="top" />
</div> </div>
<div v-if="slots.header" class="px-4"> <div v-if="slots.header" class="px-4 pb-4">
<slot name="header" /> <slot name="header" />
</div> </div>
</div> </div>

View File

@@ -39,6 +39,14 @@
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab> <Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab> <Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
</TabList> </TabList>
<!-- Search Bar -->
<div class="pt-2">
<SearchBox
v-model="searchQuery"
:placeholder="$t('sideToolbar.searchAssets')"
size="lg"
/>
</div>
</template> </template>
<template #body> <template #body>
<!-- Loading state --> <!-- Loading state -->
@@ -66,7 +74,7 @@
:grid-style="{ :grid-style="{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem', padding: '0 0.5rem',
gap: '0.5rem' gap: '0.5rem'
}" }"
@approach-end="handleApproachEnd" @approach-end="handleApproachEnd"
@@ -157,6 +165,7 @@ import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue' import TextButton from '@/components/button/TextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.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 ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue' import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue' import TabList from '@/components/tab/TabList.vue'
@@ -165,6 +174,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets' import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection' import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions' import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { ResultItemImpl } from '@/stores/queueStore' import { ResultItemImpl } from '@/stores/queueStore'
@@ -228,13 +238,21 @@ const currentGalleryAssetId = ref<string | null>(null)
const folderAssets = ref<AssetItem[]>([]) const folderAssets = ref<AssetItem[]>([])
const displayAssets = computed(() => { // Base assets before search filtering
const baseAssets = computed(() => {
if (isInFolderView.value) { if (isInFolderView.value) {
return folderAssets.value return folderAssets.value
} }
return mediaAssets.value return mediaAssets.value
}) })
// Use media asset filtering composable
const { searchQuery, filteredAssets } = useMediaAssetFiltering(baseAssets)
const displayAssets = computed(() => {
return filteredAssets.value
})
watch(displayAssets, (newAssets) => { watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) { if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex( const newIndex = newAssets.findIndex(
@@ -293,6 +311,8 @@ watch(
activeTab, activeTab,
() => { () => {
clearSelection() clearSelection()
// Clear search when switching tabs
searchQuery.value = ''
// Reset pagination state when tab changes // Reset pagination state when tab changes
void refreshAssets() void refreshAssets()
}, },
@@ -350,6 +370,7 @@ const exitFolderView = () => {
folderPromptId.value = null folderPromptId.value = null
folderExecutionTime.value = undefined folderExecutionTime.value = undefined
folderAssets.value = [] folderAssets.value = []
searchQuery.value = ''
clearSelection() clearSelection()
} }

View File

@@ -620,6 +620,7 @@
"assets": "Assets", "assets": "Assets",
"mediaAssets": "Media Assets", "mediaAssets": "Media Assets",
"backToAssets": "Back to all assets", "backToAssets": "Back to all assets",
"searchAssets": "Search assets...",
"labels": { "labels": {
"queue": "Queue", "queue": "Queue",
"nodes": "Nodes", "nodes": "Nodes",

View File

@@ -0,0 +1,37 @@
import { refDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Media Asset Filtering composable
* Manages search, filter, and sort for media assets
*/
export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
const searchQuery = ref('')
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const fuseOptions = {
keys: ['name'],
threshold: 0.4,
includeScore: true
}
const fuse = computed(() => new Fuse(assets.value, fuseOptions))
const filteredAssets = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
return assets.value
}
const results = fuse.value.search(debouncedSearchQuery.value)
return results.map((result) => result.item)
})
return {
searchQuery,
filteredAssets
}
}