diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index 9b793fe91..a10d1d790 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -56,7 +56,8 @@ describe('formatUtil', () => { { filename: 'image.jpeg', expected: 'image' }, { filename: 'animation.gif', expected: 'image' }, { filename: 'web.webp', expected: 'image' }, - { filename: 'bitmap.bmp', expected: 'image' } + { filename: 'bitmap.bmp', expected: 'image' }, + { filename: 'modern.avif', expected: 'image' } ] it.for(imageTestCases)( @@ -96,26 +97,37 @@ describe('formatUtil', () => { expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D') expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D') expect(getMediaTypeFromFilename('binary.glb')).toBe('3D') + expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D') + }) + }) + + describe('text files', () => { + it('should identify text file extensions correctly', () => { + expect(getMediaTypeFromFilename('notes.txt')).toBe('text') + expect(getMediaTypeFromFilename('readme.md')).toBe('text') + expect(getMediaTypeFromFilename('data.json')).toBe('text') + expect(getMediaTypeFromFilename('table.csv')).toBe('text') + expect(getMediaTypeFromFilename('config.yaml')).toBe('text') }) }) describe('edge cases', () => { it('should handle empty strings', () => { - expect(getMediaTypeFromFilename('')).toBe('image') + expect(getMediaTypeFromFilename('')).toBe('other') }) it('should handle files without extensions', () => { - expect(getMediaTypeFromFilename('README')).toBe('image') + expect(getMediaTypeFromFilename('README')).toBe('other') }) it('should handle unknown extensions', () => { - expect(getMediaTypeFromFilename('document.pdf')).toBe('image') - expect(getMediaTypeFromFilename('data.json')).toBe('image') + expect(getMediaTypeFromFilename('document.pdf')).toBe('other') + expect(getMediaTypeFromFilename('archive.bin')).toBe('other') }) it('should handle files with multiple dots', () => { expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image') - expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image') + expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other') }) it('should handle paths with directories', () => { @@ -124,8 +136,8 @@ describe('formatUtil', () => { }) it('should handle null and undefined gracefully', () => { - expect(getMediaTypeFromFilename(null)).toBe('image') - expect(getMediaTypeFromFilename(undefined)).toBe('image') + expect(getMediaTypeFromFilename(null)).toBe('other') + expect(getMediaTypeFromFilename(undefined)).toBe('other') }) it('should handle special characters in filenames', () => { diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index d01820fe6..f610a0a8e 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -494,19 +494,41 @@ export function formatDuration(milliseconds: number): string { return parts.join(' ') } -const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const +const IMAGE_EXTENSIONS = [ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'bmp', + 'avif', + 'tif', + 'tiff' +] as const const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const -const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const +const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const +const TEXT_EXTENSIONS = [ + 'txt', + 'md', + 'markdown', + 'json', + 'csv', + 'yaml', + 'yml', + 'xml', + 'log' +] as const -const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const -type MediaType = (typeof MEDIA_TYPES)[number] +const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const +export type MediaType = (typeof MEDIA_TYPES)[number] // Type guard helper for checking array membership type ImageExtension = (typeof IMAGE_EXTENSIONS)[number] type VideoExtension = (typeof VIDEO_EXTENSIONS)[number] type AudioExtension = (typeof AUDIO_EXTENSIONS)[number] type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number] +type TextExtension = (typeof TEXT_EXTENSIONS)[number] /** * Truncates a filename while preserving the extension @@ -543,20 +565,21 @@ export function truncateFilename( /** * Determines the media type from a filename's extension (singular form) * @param filename The filename to analyze - * @returns The media type: 'image', 'video', 'audio', or '3D' + * @returns The media type: 'image', 'video', 'audio', '3D', 'text', or 'other' */ export function getMediaTypeFromFilename( filename: string | null | undefined ): MediaType { - if (!filename) return 'image' + if (!filename) return 'other' const ext = filename.split('.').pop()?.toLowerCase() - if (!ext) return 'image' + if (!ext) return 'other' // Type-safe array includes check using type assertion if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image' if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video' if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio' if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D' + if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text' - return 'image' + return 'other' } diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts b/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts index 70dc82493..add90360a 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts +++ b/src/components/sidebar/tabs/AssetsSidebarListView.stories.ts @@ -112,6 +112,22 @@ const sampleAssets: AssetItem[] = [ created_at: baseTimestamp, size: 134217728, tags: [] + }, + { + id: 'asset-text-1', + name: 'generation-notes.txt', + created_at: baseTimestamp, + preview_url: '/assets/images/default-template.png', + size: 2048, + tags: [] + }, + { + id: 'asset-other-1', + name: 'workflow-payload.bin', + created_at: baseTimestamp, + preview_url: '/assets/images/default-template.png', + size: 4096, + tags: [] } ] @@ -134,6 +150,16 @@ export const RunningAndGenerated: Story = { render: renderAssetsSidebarListView } +export const TextAndMiscGeneratedAssets: Story = { + args: { + assets: sampleAssets.filter((asset) => + ['.txt', '.bin'].some((suffix) => asset.name.endsWith(suffix)) + ), + jobs: [] + }, + render: renderAssetsSidebarListView +} + function renderAssetsSidebarListView(args: StoryArgs) { return { components: { AssetsSidebarListView }, diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.test.ts b/src/components/sidebar/tabs/AssetsSidebarListView.test.ts index a99e23b62..aa2d704f1 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.test.ts +++ b/src/components/sidebar/tabs/AssetsSidebarListView.test.ts @@ -89,4 +89,21 @@ describe('AssetsSidebarListView', () => { expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4') expect(assetListItem?.props('isVideoPreview')).toBe(true) }) + + it('uses icon fallback for text assets even when preview_url exists', () => { + const textAsset = { + ...buildAsset('text-asset', 'notes.txt'), + preview_url: '/api/view/notes.txt', + user_metadata: {} + } satisfies AssetItem + + const wrapper = mountListView([buildOutputItem(textAsset)]) + + const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' }) + const assetListItem = listItems.at(-1) + + expect(assetListItem).toBeDefined() + expect(assetListItem?.props('previewUrl')).toBe('') + expect(assetListItem?.props('isVideoPreview')).toBe(false) + }) }) diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.vue b/src/components/sidebar/tabs/AssetsSidebarListView.vue index 0f06ff7ca..e0192e2ae 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.vue +++ b/src/components/sidebar/tabs/AssetsSidebarListView.vue @@ -43,7 +43,7 @@ item.isChild && 'pl-6' ) " - :preview-url="item.asset.preview_url" + :preview-url="getAssetPreviewUrl(item.asset)" :preview-alt="item.asset.name" :icon-name="iconForMediaType(getAssetMediaType(item.asset))" :is-video-preview="isVideoAsset(item.asset)" @@ -142,6 +142,14 @@ function isVideoAsset(asset: AssetItem): boolean { return getAssetMediaType(asset) === 'video' } +function getAssetPreviewUrl(asset: AssetItem): string { + const mediaType = getAssetMediaType(asset) + if (mediaType === 'image' || mediaType === 'video') { + return asset.preview_url || '' + } + return '' +} + function getAssetSecondaryText(asset: AssetItem): string { const metadata = getOutputAssetMetadata(asset.user_metadata) if (typeof metadata?.executionTimeInSeconds === 'number') { diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index ccf7b9868..9cfbcdf93 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -204,13 +204,22 @@ import { } from '@vueuse/core' import Divider from 'primevue/divider' import { useToast } from 'primevue/usetoast' -import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { + computed, + defineAsyncComponent, + nextTick, + onMounted, + onUnmounted, + ref, + watch +} from 'vue' import { useI18n } from 'vue-i18n' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' // Lazy-loaded to avoid pulling THREE.js into the main bundle -const Load3dViewerContent = () => - import('@/components/load3d/Load3dViewerContent.vue') +const Load3dViewerContent = defineAsyncComponent( + () => import('@/components/load3d/Load3dViewerContent.vue') +) import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue' import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue' import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue' diff --git a/src/platform/assets/components/MediaAssetCard.stories.ts b/src/platform/assets/components/MediaAssetCard.stories.ts index d7ccc8ac0..d40bbc07f 100644 --- a/src/platform/assets/components/MediaAssetCard.stories.ts +++ b/src/platform/assets/components/MediaAssetCard.stories.ts @@ -141,6 +141,40 @@ export const AudioAsset: Story = { } } +export const TextAsset: Story = { + decorators: [ + () => ({ + template: '