Skip to content

Commit dfa1cbb

Browse files
authored
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
1 parent 08220d5 commit dfa1cbb

27 files changed

+2636
-61
lines changed

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ vite.config.mts.timestamp-*.mjs
7878
*storybook.log
7979
storybook-static
8080

81-
82-
81+
# MCP Servers
82+
.playwright-mcp/*
8383

8484
.nx/cache
8585
.nx/workspace-data

.storybook/main.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,32 @@ const config: StorybookConfig = {
1515
async viteFinal(config) {
1616
// Use dynamic import to avoid CJS deprecation warning
1717
const { mergeConfig } = await import('vite')
18+
const { default: tailwindcss } = await import('@tailwindcss/vite')
1819

1920
// Filter out any plugins that might generate import maps
2021
if (config.plugins) {
21-
config.plugins = config.plugins.filter((plugin: any) => {
22-
if (plugin && plugin.name && plugin.name.includes('import-map')) {
23-
return false
24-
}
25-
return true
26-
})
22+
config.plugins = config.plugins
23+
// Type guard: ensure we have valid plugin objects with names
24+
.filter(
25+
(plugin): plugin is NonNullable<typeof plugin> & { name: string } => {
26+
return (
27+
plugin !== null &&
28+
plugin !== undefined &&
29+
typeof plugin === 'object' &&
30+
'name' in plugin &&
31+
typeof plugin.name === 'string'
32+
)
33+
}
34+
)
35+
// Business logic: filter out import-map plugins
36+
.filter((plugin) => !plugin.name.includes('import-map'))
2737
}
2838

2939
return mergeConfig(config, {
3040
// Replace plugins entirely to avoid inheritance issues
3141
plugins: [
3242
// Only include plugins we explicitly need for Storybook
43+
tailwindcss(),
3344
Icons({
3445
compiler: 'vue3',
3546
customCollections: {

.storybook/preview.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
import { definePreset } from '@primevue/themes'
22
import Aura from '@primevue/themes/aura'
33
import { setup } from '@storybook/vue3'
4-
import type { Preview } from '@storybook/vue3-vite'
4+
import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
55
import { createPinia } from 'pinia'
66
import 'primeicons/primeicons.css'
77
import PrimeVue from 'primevue/config'
88
import ConfirmationService from 'primevue/confirmationservice'
99
import ToastService from 'primevue/toastservice'
1010
import Tooltip from 'primevue/tooltip'
1111

12-
import '../src/assets/css/style.css'
13-
import { i18n } from '../src/i18n'
14-
import '../src/lib/litegraph/public/css/litegraph.css'
15-
import { useWidgetStore } from '../src/stores/widgetStore'
16-
import { useColorPaletteStore } from '../src/stores/workspace/colorPaletteStore'
12+
import '@/assets/css/style.css'
13+
import { i18n } from '@/i18n'
14+
import '@/lib/litegraph/public/css/litegraph.css'
1715

1816
const ComfyUIPreset = definePreset(Aura, {
1917
semantic: {
@@ -25,13 +23,11 @@ const ComfyUIPreset = definePreset(Aura, {
2523
// Setup Vue app for Storybook
2624
setup((app) => {
2725
app.directive('tooltip', Tooltip)
28-
const pinia = createPinia()
29-
app.use(pinia)
3026

31-
// Initialize stores
32-
useColorPaletteStore(pinia)
33-
useWidgetStore(pinia)
27+
// Create Pinia instance
28+
const pinia = createPinia()
3429

30+
app.use(pinia)
3531
app.use(i18n)
3632
app.use(PrimeVue, {
3733
theme: {
@@ -50,8 +46,8 @@ setup((app) => {
5046
app.use(ToastService)
5147
})
5248

53-
// Dark theme decorator
54-
export const withTheme = (Story: any, context: any) => {
49+
// Theme and dialog decorator
50+
export const withTheme = (Story: StoryFn, context: StoryContext) => {
5551
const theme = context.globals.theme || 'light'
5652

5753
// Apply theme class to document root
@@ -63,7 +59,7 @@ export const withTheme = (Story: any, context: any) => {
6359
document.body.classList.remove('dark-theme')
6460
}
6561

66-
return Story()
62+
return Story(context.args, context)
6763
}
6864

6965
const preview: Preview = {

src/locales/en/main.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1862,5 +1862,17 @@
18621862
"showGroups": "Show Frames/Groups",
18631863
"renderBypassState": "Render Bypass State",
18641864
"renderErrorState": "Render Error State"
1865+
},
1866+
"assetBrowser": {
1867+
"assets": "Assets",
1868+
"browseAssets": "Browse Assets",
1869+
"noAssetsFound": "No assets found",
1870+
"tryAdjustingFilters": "Try adjusting your search or filters",
1871+
"loadingModels": "Loading {type}...",
1872+
"connectionError": "Please check your connection and try again",
1873+
"noModelsInFolder": "No {type} available in this folder",
1874+
"searchAssetsPlaceholder": "Search assets...",
1875+
"allModels": "All Models",
1876+
"unknown": "Unknown"
18651877
}
18661878
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<template>
2+
<div class="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1">
3+
<span
4+
v-for="badge in badges"
5+
:key="badge.label"
6+
:class="
7+
cn(
8+
'px-2 py-1 rounded text-xs font-medium uppercase tracking-wider text-white',
9+
getBadgeColor(badge.type)
10+
)
11+
"
12+
>
13+
{{ badge.label }}
14+
</span>
15+
</div>
16+
</template>
17+
18+
<script setup lang="ts">
19+
import { cn } from '@/utils/tailwindUtil'
20+
21+
type AssetBadge = {
22+
label: string
23+
type: 'type' | 'base' | 'size'
24+
}
25+
26+
defineProps<{
27+
badges: AssetBadge[]
28+
}>()
29+
30+
function getBadgeColor(type: AssetBadge['type']): string {
31+
switch (type) {
32+
case 'type':
33+
return 'bg-blue-100/90 dark-theme:bg-blue-100/80'
34+
case 'base':
35+
return 'bg-success-100/90 dark-theme:bg-success-100/80'
36+
case 'size':
37+
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
38+
default:
39+
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
40+
}
41+
}
42+
</script>
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
2+
3+
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
4+
import {
5+
createMockAssets,
6+
mockAssets
7+
} from '@/platform/assets/fixtures/ui-mock-assets'
8+
9+
// Story arguments interface
10+
interface StoryArgs {
11+
nodeType: string
12+
inputName: string
13+
currentValue: string
14+
showLeftPanel?: boolean
15+
}
16+
17+
const meta: Meta<StoryArgs> = {
18+
title: 'Platform/Assets/AssetBrowserModal',
19+
component: AssetBrowserModal,
20+
parameters: {
21+
layout: 'fullscreen'
22+
},
23+
argTypes: {
24+
nodeType: {
25+
control: 'select',
26+
options: ['CheckpointLoaderSimple', 'VAELoader', 'ControlNetLoader'],
27+
description: 'ComfyUI node type for context'
28+
},
29+
inputName: {
30+
control: 'select',
31+
options: ['ckpt_name', 'vae_name', 'control_net_name'],
32+
description: 'Widget input name'
33+
},
34+
currentValue: {
35+
control: 'text',
36+
description: 'Current selected asset value'
37+
},
38+
showLeftPanel: {
39+
control: 'boolean',
40+
description: 'Whether to show the left panel with categories'
41+
}
42+
}
43+
}
44+
45+
export default meta
46+
type Story = StoryObj<typeof meta>
47+
48+
// Modal Layout Stories
49+
export const Default: Story = {
50+
args: {
51+
nodeType: 'CheckpointLoaderSimple',
52+
inputName: 'ckpt_name',
53+
currentValue: '',
54+
showLeftPanel: false
55+
},
56+
render: (args) => ({
57+
components: { AssetBrowserModal },
58+
setup() {
59+
const onAssetSelect = (asset: any) => {
60+
console.log('Selected asset:', asset)
61+
}
62+
const onClose = () => {
63+
console.log('Modal closed')
64+
}
65+
66+
return {
67+
...args,
68+
onAssetSelect,
69+
onClose,
70+
assets: mockAssets
71+
}
72+
},
73+
template: `
74+
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
75+
<AssetBrowserModal
76+
:node-type="nodeType"
77+
:input-name="inputName"
78+
:show-left-panel="showLeftPanel"
79+
:assets="assets"
80+
@asset-select="onAssetSelect"
81+
@close="onClose"
82+
/>
83+
</div>
84+
`
85+
})
86+
}
87+
88+
// Story demonstrating single asset type (auto-hides left panel)
89+
export const SingleAssetType: Story = {
90+
args: {
91+
nodeType: 'CheckpointLoaderSimple',
92+
inputName: 'ckpt_name',
93+
currentValue: '',
94+
showLeftPanel: false
95+
},
96+
render: (args) => ({
97+
components: { AssetBrowserModal },
98+
setup() {
99+
const onAssetSelect = (asset: any) => {
100+
console.log('Selected asset:', asset)
101+
}
102+
const onClose = () => {
103+
console.log('Modal closed')
104+
}
105+
106+
// Create assets with only one type (checkpoints)
107+
const singleTypeAssets = createMockAssets(15).map((asset) => ({
108+
...asset,
109+
type: 'checkpoint'
110+
}))
111+
112+
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
113+
},
114+
template: `
115+
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
116+
<AssetBrowserModal
117+
:node-type="nodeType"
118+
:input-name="inputName"
119+
:show-left-panel="showLeftPanel"
120+
:assets="assets"
121+
@asset-select="onAssetSelect"
122+
@close="onClose"
123+
/>
124+
</div>
125+
`
126+
}),
127+
parameters: {
128+
docs: {
129+
description: {
130+
story:
131+
'Modal with assets of only one type (checkpoint) - left panel auto-hidden.'
132+
}
133+
}
134+
}
135+
}
136+
137+
// Story with left panel explicitly hidden
138+
export const NoLeftPanel: Story = {
139+
args: {
140+
nodeType: 'CheckpointLoaderSimple',
141+
inputName: 'ckpt_name',
142+
currentValue: '',
143+
showLeftPanel: false
144+
},
145+
render: (args) => ({
146+
components: { AssetBrowserModal },
147+
setup() {
148+
const onAssetSelect = (asset: any) => {
149+
console.log('Selected asset:', asset)
150+
}
151+
const onClose = () => {
152+
console.log('Modal closed')
153+
}
154+
155+
return { ...args, onAssetSelect, onClose, assets: mockAssets }
156+
},
157+
template: `
158+
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
159+
<AssetBrowserModal
160+
:node-type="nodeType"
161+
:input-name="inputName"
162+
:show-left-panel="showLeftPanel"
163+
:assets="assets"
164+
@asset-select="onAssetSelect"
165+
@close="onClose"
166+
/>
167+
</div>
168+
`
169+
}),
170+
parameters: {
171+
docs: {
172+
description: {
173+
story:
174+
'Modal with left panel explicitly disabled via showLeftPanel=false.'
175+
}
176+
}
177+
}
178+
}

0 commit comments

Comments
 (0)