mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-22 07:19:41 +00:00
Asset Browser Modal Component (#5607)
* [ci] ignore playwright mcp directory * [feat] add AssetBrowserModal And all related sub components * [feat] reactive filter functions * [ci] clean up storybook config * [feat] add sematic AssetCard * [fix] i love lucide * [fix] AssetCard layout issues * [fix] add AssetBadge type * [fix] simplify useAssetBrowser * [fix] modal layout * [fix] simplify useAssetBrowserDialog * [fix] add tailwind back to storybook * [fix] better reponsive layout * [fix] missed i18n string * [fix] missing i18n translations * [fix] remove erroneous prevent on keyboard.space * [feat] add asset metadata validation utilities * [fix] remove erroneous test code * [fix] remove forced min and max width on AssetCard * [fix] import statement nits
This commit is contained in:
42
src/platform/assets/components/AssetBadgeGroup.vue
Normal file
42
src/platform/assets/components/AssetBadgeGroup.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1">
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
:class="
|
||||
cn(
|
||||
'px-2 py-1 rounded text-xs font-medium uppercase tracking-wider text-white',
|
||||
getBadgeColor(badge.type)
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type AssetBadge = {
|
||||
label: string
|
||||
type: 'type' | 'base' | 'size'
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
badges: AssetBadge[]
|
||||
}>()
|
||||
|
||||
function getBadgeColor(type: AssetBadge['type']): string {
|
||||
switch (type) {
|
||||
case 'type':
|
||||
return 'bg-blue-100/90 dark-theme:bg-blue-100/80'
|
||||
case 'base':
|
||||
return 'bg-success-100/90 dark-theme:bg-success-100/80'
|
||||
case 'size':
|
||||
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
|
||||
default:
|
||||
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
178
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
178
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import {
|
||||
createMockAssets,
|
||||
mockAssets
|
||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
// Story arguments interface
|
||||
interface StoryArgs {
|
||||
nodeType: string
|
||||
inputName: string
|
||||
currentValue: string
|
||||
showLeftPanel?: boolean
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Platform/Assets/AssetBrowserModal',
|
||||
component: AssetBrowserModal,
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
argTypes: {
|
||||
nodeType: {
|
||||
control: 'select',
|
||||
options: ['CheckpointLoaderSimple', 'VAELoader', 'ControlNetLoader'],
|
||||
description: 'ComfyUI node type for context'
|
||||
},
|
||||
inputName: {
|
||||
control: 'select',
|
||||
options: ['ckpt_name', 'vae_name', 'control_net_name'],
|
||||
description: 'Widget input name'
|
||||
},
|
||||
currentValue: {
|
||||
control: 'text',
|
||||
description: 'Current selected asset value'
|
||||
},
|
||||
showLeftPanel: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show the left panel with categories'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Modal Layout Stories
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: any) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
return {
|
||||
...args,
|
||||
onAssetSelect,
|
||||
onClose,
|
||||
assets: mockAssets
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||
<AssetBrowserModal
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story demonstrating single asset type (auto-hides left panel)
|
||||
export const SingleAssetType: Story = {
|
||||
args: {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: any) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
// Create assets with only one type (checkpoints)
|
||||
const singleTypeAssets = createMockAssets(15).map((asset) => ({
|
||||
...asset,
|
||||
type: 'checkpoint'
|
||||
}))
|
||||
|
||||
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||
<AssetBrowserModal
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Modal with assets of only one type (checkpoint) - left panel auto-hidden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Story with left panel explicitly hidden
|
||||
export const NoLeftPanel: Story = {
|
||||
args: {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: any) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
return { ...args, onAssetSelect, onClose, assets: mockAssets }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||
<AssetBrowserModal
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Modal with left panel explicitly disabled via showLeftPanel=false.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/platform/assets/components/AssetBrowserModal.vue
Normal file
95
src/platform/assets/components/AssetBrowserModal.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
data-component-id="AssetBrowserModal"
|
||||
class="size-full max-h-full max-w-full min-w-0"
|
||||
:content-title="contentTitle"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template v-if="shouldShowLeftPanel" #leftPanel>
|
||||
<LeftSidePanel
|
||||
v-model="selectedCategory"
|
||||
data-component-id="AssetBrowserModal-LeftSidePanel"
|
||||
:nav-items="availableCategories"
|
||||
>
|
||||
<template #header-icon>
|
||||
<i-lucide:folder class="size-4" />
|
||||
</template>
|
||||
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
|
||||
</LeftSidePanel>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
|
||||
class="max-w-96"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<AssetGrid
|
||||
:assets="filteredAssets"
|
||||
@asset-select="handleAssetSelectAndEmit"
|
||||
/>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const props = defineProps<{
|
||||
nodeType?: string
|
||||
inputName?: string
|
||||
onSelect?: (assetPath: string) => void
|
||||
onClose?: () => void
|
||||
showLeftPanel?: boolean
|
||||
assets?: AssetItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// Use AssetBrowser composable for all business logic
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
selectAsset
|
||||
} = useAssetBrowser(props.assets)
|
||||
|
||||
// Dialog controls panel visibility via prop
|
||||
const shouldShowLeftPanel = computed(() => {
|
||||
return props.showLeftPanel ?? true
|
||||
})
|
||||
|
||||
// Handle close button - call both the prop callback and emit the event
|
||||
const handleClose = () => {
|
||||
props.onClose?.()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Handle asset selection and emit to parent
|
||||
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
|
||||
selectAsset(asset) // This logs the selection for dev mode
|
||||
emit('asset-select', asset) // Emit the full asset object
|
||||
|
||||
// Call prop callback if provided
|
||||
if (props.onSelect) {
|
||||
props.onSelect(asset.name) // Use asset name as the asset path
|
||||
}
|
||||
}
|
||||
</script>
|
||||
182
src/platform/assets/components/AssetCard.stories.ts
Normal file
182
src/platform/assets/components/AssetCard.stories.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
// Use the first mock asset as base and transform it to display format
|
||||
const baseAsset = mockAssets[0]
|
||||
const createAssetData = (
|
||||
overrides: Partial<AssetDisplayItem> = {}
|
||||
): AssetDisplayItem => ({
|
||||
...baseAsset,
|
||||
description:
|
||||
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
|
||||
formattedSize: '2.1 GB',
|
||||
badges: [
|
||||
{ label: 'checkpoints', type: 'type' },
|
||||
{ label: '2.1 GB', type: 'size' }
|
||||
],
|
||||
stats: {
|
||||
formattedDate: '3/15/25',
|
||||
downloadCount: '1.8k',
|
||||
stars: '4.2k'
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
|
||||
const meta: Meta<typeof AssetCard> = {
|
||||
title: 'Platform/Assets/AssetCard',
|
||||
component: AssetCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
asset: createAssetData(),
|
||||
interactive: true
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||
})
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Default AssetCard with complete data including badges and all stats.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NonInteractive: Story = {
|
||||
args: {
|
||||
asset: createAssetData(),
|
||||
interactive: false
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||
})
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'AssetCard in non-interactive mode - renders as div without button semantics.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EdgeCases: Story = {
|
||||
render: () => ({
|
||||
components: { AssetCard },
|
||||
setup() {
|
||||
const edgeCases = [
|
||||
// Default case for comparison
|
||||
createAssetData({
|
||||
name: 'Complete Data',
|
||||
description: 'Asset with all data present for comparison'
|
||||
}),
|
||||
// No badges
|
||||
createAssetData({
|
||||
id: 'no-badges',
|
||||
name: 'No Badges',
|
||||
description: 'Testing graceful handling when badges are not provided',
|
||||
badges: []
|
||||
}),
|
||||
// No stars
|
||||
createAssetData({
|
||||
id: 'no-stars',
|
||||
name: 'No Stars',
|
||||
description: 'Testing missing stars data gracefully',
|
||||
stats: {
|
||||
downloadCount: '1.8k',
|
||||
formattedDate: '3/15/25'
|
||||
}
|
||||
}),
|
||||
// No downloads
|
||||
createAssetData({
|
||||
id: 'no-downloads',
|
||||
name: 'No Downloads',
|
||||
description: 'Testing missing downloads data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
formattedDate: '3/15/25'
|
||||
}
|
||||
}),
|
||||
// No date
|
||||
createAssetData({
|
||||
id: 'no-date',
|
||||
name: 'No Date',
|
||||
description: 'Testing missing date data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
downloadCount: '1.8k'
|
||||
}
|
||||
}),
|
||||
// No stats at all
|
||||
createAssetData({
|
||||
id: 'no-stats',
|
||||
name: 'No Stats',
|
||||
description: 'Testing when all stats are missing',
|
||||
stats: {}
|
||||
}),
|
||||
// Long description
|
||||
createAssetData({
|
||||
id: 'long-desc',
|
||||
name: 'Long Description',
|
||||
description:
|
||||
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
|
||||
}),
|
||||
// Minimal data
|
||||
createAssetData({
|
||||
id: 'minimal',
|
||||
name: 'Minimal',
|
||||
description: 'Basic model',
|
||||
tags: ['models'],
|
||||
badges: [],
|
||||
stats: {}
|
||||
})
|
||||
]
|
||||
|
||||
return { edgeCases }
|
||||
},
|
||||
template: `
|
||||
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-gray-900">
|
||||
<AssetCard
|
||||
v-for="asset in edgeCases"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
:interactive="true"
|
||||
@select="(asset) => console.log('Selected:', asset)"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'All AssetCard edge cases in a grid layout to test graceful handling of missing data, badges, stats, and long descriptions.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/platform/assets/components/AssetCard.vue
Normal file
111
src/platform/assets/components/AssetCard.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<component
|
||||
:is="interactive ? 'button' : 'div'"
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
v-bind="elementProps"
|
||||
:class="
|
||||
cn(
|
||||
// Base layout and container styles (always applied)
|
||||
'rounded-xl overflow-hidden transition-all duration-200',
|
||||
// Button-specific styles
|
||||
interactive && [
|
||||
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
|
||||
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
|
||||
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
|
||||
],
|
||||
// Div-specific styles
|
||||
!interactive && [
|
||||
'bg-ivory-100 border border-gray-300',
|
||||
'dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600'
|
||||
]
|
||||
)
|
||||
"
|
||||
@click="interactive && $emit('select', asset)"
|
||||
@keydown.enter="interactive && $emit('select', asset)"
|
||||
>
|
||||
<div class="relative w-full aspect-square overflow-hidden">
|
||||
<div
|
||||
class="w-full h-full bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600 flex items-center justify-center"
|
||||
></div>
|
||||
<AssetBadgeGroup :badges="asset.badges" />
|
||||
</div>
|
||||
<div class="p-4 h-32 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
'text-slate-800',
|
||||
'dark-theme:text-white'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ asset.name }}
|
||||
</h3>
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
||||
'text-stone-300',
|
||||
'dark-theme:text-stone-200'
|
||||
)
|
||||
"
|
||||
:title="asset.description"
|
||||
>
|
||||
{{ asset.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex gap-4 text-xs',
|
||||
'text-stone-400',
|
||||
'dark-theme:text-stone-300'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span v-if="asset.stats.stars" class="flex items-center gap-1">
|
||||
<i-lucide:star class="size-3" />
|
||||
{{ asset.stats.stars }}
|
||||
</span>
|
||||
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
|
||||
<i-lucide:download class="size-3" />
|
||||
{{ asset.stats.downloadCount }}
|
||||
</span>
|
||||
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
|
||||
<i-lucide:clock class="size-3" />
|
||||
{{ asset.stats.formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
asset: AssetDisplayItem
|
||||
interactive?: boolean
|
||||
}>()
|
||||
|
||||
const elementProps = computed(() =>
|
||||
props.interactive
|
||||
? {
|
||||
type: 'button',
|
||||
'aria-label': `Select asset ${props.asset.name}`
|
||||
}
|
||||
: {}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
select: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
</script>
|
||||
102
src/platform/assets/components/AssetFilterBar.vue
Normal file
102
src/platform/assets/components/AssetFilterBar.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div :class="containerClasses" data-component-id="asset-filter-bar">
|
||||
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
|
||||
<MultiSelect
|
||||
v-model="fileFormats"
|
||||
label="File formats"
|
||||
:options="fileFormatOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-file-formats"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-model="baseModels"
|
||||
label="Base models"
|
||||
:options="baseModelOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-base-models"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
label="Sort by"
|
||||
:options="sortOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-sort"
|
||||
@update:model-value="handleFilterChange"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:arrow-up-down class="size-3" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export interface FilterState {
|
||||
fileFormats: string[]
|
||||
baseModels: string[]
|
||||
sortBy: string
|
||||
}
|
||||
|
||||
const fileFormats = ref<SelectOption[]>([])
|
||||
const baseModels = ref<SelectOption[]>([])
|
||||
const sortBy = ref('name-asc')
|
||||
|
||||
// TODO: Make fileFormatOptions configurable via props or assetService
|
||||
// Should support dynamic file formats based on available assets or server capabilities
|
||||
const fileFormatOptions = [
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.safetensors', value: 'safetensors' },
|
||||
{ name: '.pt', value: 'pt' }
|
||||
]
|
||||
|
||||
// TODO: Make baseModelOptions configurable via props or assetService
|
||||
// Should support dynamic base models based on available assets or server detection
|
||||
const baseModelOptions = [
|
||||
{ name: 'SD 1.5', value: 'sd15' },
|
||||
{ name: 'SD XL', value: 'sdxl' },
|
||||
{ name: 'SD 3.5', value: 'sd35' }
|
||||
]
|
||||
|
||||
// TODO: Make sortOptions configurable via props
|
||||
// Different asset types might need different sorting options
|
||||
const sortOptions = [
|
||||
{ name: 'A-Z', value: 'name-asc' },
|
||||
{ name: 'Z-A', value: 'name-desc' },
|
||||
{ name: 'Recent', value: 'recent' },
|
||||
{ name: 'Popular', value: 'popular' }
|
||||
]
|
||||
|
||||
const emit = defineEmits<{
|
||||
filterChange: [filters: FilterState]
|
||||
}>()
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex gap-4 items-center justify-between',
|
||||
'px-6 pt-2 pb-6'
|
||||
)
|
||||
const leftSideClasses = cn('flex gap-4 items-center')
|
||||
const rightSideClasses = cn('flex items-center')
|
||||
const selectClasses = cn('min-w-32')
|
||||
|
||||
function handleFilterChange() {
|
||||
emit('filterChange', {
|
||||
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
||||
sortBy: sortBy.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
70
src/platform/assets/components/AssetGrid.vue
Normal file
70
src/platform/assets/components/AssetGrid.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div
|
||||
data-component-id="AssetGrid"
|
||||
:style="gridStyle"
|
||||
role="grid"
|
||||
aria-label="Asset collection"
|
||||
:aria-rowcount="-1"
|
||||
:aria-colcount="-1"
|
||||
:aria-setsize="assets.length"
|
||||
>
|
||||
<AssetCard
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
:interactive="true"
|
||||
role="gridcell"
|
||||
@select="$emit('assetSelect', $event)"
|
||||
/>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="assets.length === 0"
|
||||
:class="
|
||||
cn(
|
||||
'col-span-full flex flex-col items-center justify-center py-16',
|
||||
'text-stone-300 dark-theme:text-stone-200'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i-lucide:search class="size-10 mb-4" />
|
||||
<h3 class="text-lg font-medium mb-2">
|
||||
{{ $t('assetBrowser.noAssetsFound') }}
|
||||
</h3>
|
||||
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="col-span-full flex items-center justify-center py-16"
|
||||
>
|
||||
<i-lucide:loader
|
||||
:class="
|
||||
cn('size-6 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
assets: AssetDisplayItem[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
assetSelect: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
|
||||
// Use same grid style as BaseModalLayout
|
||||
const gridStyle = computed(() => createGridStyle())
|
||||
</script>
|
||||
Reference in New Issue
Block a user