From 8462955ca4e7c0b83499e8bf116710238542f257 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Wed, 1 Oct 2025 21:21:05 +0900 Subject: [PATCH 1/3] [feat] Add QueueAssetCard presentation components for asset library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create type-specific card components (Image, Video, Audio, Text) - Use IconButton and Card components from common library - Implement 1:1 aspect ratio for all CardTop components - Add responsive layouts without hardcoded dimensions - Support Input/Output contexts with jobId display - Add Storybook stories and unit tests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../assetLibrary/QueueAssetCard.stories.ts | 293 ++++++++++++++++++ .../assetLibrary/QueueAssetCard.vue | 112 +++++++ .../assetLibrary/QueueAssetThumbnail.vue | 148 +++++++++ .../assetLibrary/cards/QueueAudioCard.vue | 145 +++++++++ .../assetLibrary/cards/QueueImageCard.vue | 182 +++++++++++ .../assetLibrary/cards/QueueTextCard.vue | 135 ++++++++ .../assetLibrary/cards/QueueVideoCard.vue | 196 ++++++++++++ .../assetLibrary/common/JobIdSection.vue | 26 ++ src/types/media.types.ts | 33 ++ src/utils/formatUtil.ts | 16 + src/utils/media.utils.ts | 23 ++ .../assetLibrary/QueueAssetCard.test.ts | 204 ++++++++++++ 12 files changed, 1513 insertions(+) create mode 100644 src/components/assetLibrary/QueueAssetCard.stories.ts create mode 100644 src/components/assetLibrary/QueueAssetCard.vue create mode 100644 src/components/assetLibrary/QueueAssetThumbnail.vue create mode 100644 src/components/assetLibrary/cards/QueueAudioCard.vue create mode 100644 src/components/assetLibrary/cards/QueueImageCard.vue create mode 100644 src/components/assetLibrary/cards/QueueTextCard.vue create mode 100644 src/components/assetLibrary/cards/QueueVideoCard.vue create mode 100644 src/components/assetLibrary/common/JobIdSection.vue create mode 100644 src/types/media.types.ts create mode 100644 src/utils/formatUtil.ts create mode 100644 src/utils/media.utils.ts create mode 100644 tests-ui/tests/components/assetLibrary/QueueAssetCard.test.ts diff --git a/src/components/assetLibrary/QueueAssetCard.stories.ts b/src/components/assetLibrary/QueueAssetCard.stories.ts new file mode 100644 index 0000000000..f4f1933e62 --- /dev/null +++ b/src/components/assetLibrary/QueueAssetCard.stories.ts @@ -0,0 +1,293 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import type { AssetMeta } from '@/types/media.types' + +import QueueAssetCard from './QueueAssetCard.vue' + +const meta: Meta = { + title: 'AssetLibrary/QueueAssetCard', + component: QueueAssetCard, + argTypes: { + context: { + control: 'select', + options: ['input', 'output'] + }, + dense: { + control: 'boolean' + }, + loading: { + control: 'boolean' + }, + error: { + control: 'text' + } + }, + decorators: [ + () => ({ + template: '
' + }) + ] +} + +export default meta +type Story = StoryObj + +// Public sample media URLs +const SAMPLE_MEDIA = { + image: 'https://picsum.photos/400/300', + video: + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', + videoThumbnail: + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg', + audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' +} + +const sampleAsset: AssetMeta = { + id: 'asset-1', + name: 'sample-image.png', + kind: 'image', + size: 2048576, + timestamp: Date.now(), + thumbnailUrl: SAMPLE_MEDIA.image, + dimensions: { + width: 1920, + height: 1080 + } +} + +export const ImageAsset: Story = { + args: { + context: 'input', + asset: sampleAsset, + loading: false, + error: null, + dense: false + } +} + +export const VideoAsset: Story = { + args: { + context: 'input', + asset: { + ...sampleAsset, + id: 'asset-2', + name: 'Big_Buck_Bunny.mp4', + kind: 'video', + size: 10485760, + duration: 125, + thumbnailUrl: SAMPLE_MEDIA.videoThumbnail, // Poster image + videoUrl: SAMPLE_MEDIA.video, // Actual video file + dimensions: { + width: 1280, + height: 720 + } + } + } +} + +export const AudioAsset: Story = { + args: { + context: 'input', + asset: { + ...sampleAsset, + id: 'asset-3', + name: 'SoundHelix-Song.mp3', + kind: 'audio', + size: 5242880, + thumbnailUrl: SAMPLE_MEDIA.audio, + dimensions: undefined, + duration: 180 + } + } +} + +export const TextAsset: Story = { + args: { + context: 'input', + asset: { + ...sampleAsset, + id: 'asset-4', + name: 'prompt-text.txt', + kind: 'text', + size: 1024, + thumbnailUrl: undefined, + dimensions: undefined + } + } +} + +export const OtherAsset: Story = { + args: { + context: 'input', + asset: { + ...sampleAsset, + id: 'asset-5', + name: 'model.safetensors', // cspell:ignore safetensors + kind: 'other', + size: 4294967296, + thumbnailUrl: undefined, + dimensions: undefined + } + } +} + +export const OutputWithJobId: Story = { + args: { + context: 'output', + asset: { + ...sampleAsset, + jobId: 'job-123-456' + } + } +} + +export const LoadingState: Story = { + args: { + context: 'input', + asset: sampleAsset, + loading: true + } +} + +export const ErrorState: Story = { + args: { + context: 'input', + asset: sampleAsset, + error: 'Failed to load asset' + } +} + +export const DenseMode: Story = { + args: { + context: 'input', + asset: sampleAsset, + dense: true + } +} + +export const LongFileName: Story = { + args: { + context: 'input', + asset: { + ...sampleAsset, + name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png' + } + } +} + +export const WebMVideo: Story = { + args: { + context: 'input', + asset: { + id: 'asset-webm', + name: 'animated-clip.webm', + kind: 'webm', + size: 3145728, + timestamp: Date.now(), + thumbnailUrl: 'https://picsum.photos/640/360?random=webm', // Poster image + videoUrl: 'https://www.w3schools.com/html/movie.mp4', // Actual video + duration: 60, + dimensions: { + width: 640, + height: 360 + } + } + } +} + +export const GifAnimation: Story = { + args: { + context: 'input', + asset: { + id: 'asset-gif', + name: 'animation.gif', + kind: 'gif', + size: 1572864, + timestamp: Date.now(), + thumbnailUrl: + 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif', + dimensions: { + width: 480, + height: 270 + } + } + } +} + +export const WebPImage: Story = { + args: { + context: 'input', + asset: { + id: 'asset-webp', + name: 'optimized-image.webp', + kind: 'webp', + size: 524288, + timestamp: Date.now(), + thumbnailUrl: 'https://www.gstatic.com/webp/gallery/1.webp', + dimensions: { + width: 550, + height: 368 + } + } + } +} + +export const GridLayout: Story = { + render: () => ({ + components: { QueueAssetCard }, + setup() { + const assets: AssetMeta[] = [ + { + id: 'grid-1', + name: 'image-file.jpg', + kind: 'image', + size: 2097152, + timestamp: Date.now(), + thumbnailUrl: 'https://picsum.photos/400/300?random=1', + dimensions: { width: 1920, height: 1080 } + }, + { + id: 'grid-2', + name: 'video-file.mp4', + kind: 'video', + size: 10485760, + timestamp: Date.now(), + thumbnailUrl: SAMPLE_MEDIA.videoThumbnail, // Poster image + videoUrl: SAMPLE_MEDIA.video, // Actual video + duration: 120, + dimensions: { width: 1280, height: 720 } + }, + { + id: 'grid-3', + name: 'audio-file.mp3', + kind: 'audio', + size: 5242880, + timestamp: Date.now(), + thumbnailUrl: SAMPLE_MEDIA.audio, + duration: 180 + }, + { + id: 'grid-4', + name: 'animation.gif', + kind: 'gif', + size: 3145728, + timestamp: Date.now(), + thumbnailUrl: + 'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif', + dimensions: { width: 480, height: 360 } + } + ] + return { assets } + }, + template: ` +
+ +
+ ` + }) +} diff --git a/src/components/assetLibrary/QueueAssetCard.vue b/src/components/assetLibrary/QueueAssetCard.vue new file mode 100644 index 0000000000..4428f63c4d --- /dev/null +++ b/src/components/assetLibrary/QueueAssetCard.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/components/assetLibrary/QueueAssetThumbnail.vue b/src/components/assetLibrary/QueueAssetThumbnail.vue new file mode 100644 index 0000000000..9fd4140a01 --- /dev/null +++ b/src/components/assetLibrary/QueueAssetThumbnail.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/components/assetLibrary/cards/QueueAudioCard.vue b/src/components/assetLibrary/cards/QueueAudioCard.vue new file mode 100644 index 0000000000..21abb19e26 --- /dev/null +++ b/src/components/assetLibrary/cards/QueueAudioCard.vue @@ -0,0 +1,145 @@ + + + diff --git a/src/components/assetLibrary/cards/QueueImageCard.vue b/src/components/assetLibrary/cards/QueueImageCard.vue new file mode 100644 index 0000000000..cda339f99d --- /dev/null +++ b/src/components/assetLibrary/cards/QueueImageCard.vue @@ -0,0 +1,182 @@ + + + diff --git a/src/components/assetLibrary/cards/QueueTextCard.vue b/src/components/assetLibrary/cards/QueueTextCard.vue new file mode 100644 index 0000000000..5d2b8055a8 --- /dev/null +++ b/src/components/assetLibrary/cards/QueueTextCard.vue @@ -0,0 +1,135 @@ + + + diff --git a/src/components/assetLibrary/cards/QueueVideoCard.vue b/src/components/assetLibrary/cards/QueueVideoCard.vue new file mode 100644 index 0000000000..0a996947df --- /dev/null +++ b/src/components/assetLibrary/cards/QueueVideoCard.vue @@ -0,0 +1,196 @@ + + + diff --git a/src/components/assetLibrary/common/JobIdSection.vue b/src/components/assetLibrary/common/JobIdSection.vue new file mode 100644 index 0000000000..538cf1b31a --- /dev/null +++ b/src/components/assetLibrary/common/JobIdSection.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/types/media.types.ts b/src/types/media.types.ts new file mode 100644 index 0000000000..ae17fe06fd --- /dev/null +++ b/src/types/media.types.ts @@ -0,0 +1,33 @@ +/** + * Media types for Asset Library + */ + +export type MediaKind = + | 'video' + | 'webm' + | 'webp' + | 'gif' + | 'audio' + | 'image' + | 'pose' + | 'text' + | 'other' + +export type AssetContext = 'input' | 'output' + +export interface AssetMeta { + id: string + name: string + kind: MediaKind + size: number + timestamp: number + thumbnailUrl?: string + videoUrl?: string // Actual video URL for video types + jobId?: string // Only for output context + duration?: number // For video/audio + isMulti?: boolean // indicates multiple files grouped as one + dimensions?: { + width: number + height: number + } +} diff --git a/src/utils/formatUtil.ts b/src/utils/formatUtil.ts new file mode 100644 index 0000000000..cf703759df --- /dev/null +++ b/src/utils/formatUtil.ts @@ -0,0 +1,16 @@ +/** + * Format bytes to human-readable size + * Copied from packages/shared-frontend-utils/src/formatUtil.ts + */ +export function formatSize(value?: number) { + if (value === null || value === undefined) { + return '-' + } + + const bytes = value + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` +} diff --git a/src/utils/media.utils.ts b/src/utils/media.utils.ts new file mode 100644 index 0000000000..46ec422b76 --- /dev/null +++ b/src/utils/media.utils.ts @@ -0,0 +1,23 @@ +/** + * Media utility functions for Asset Library + */ +import type { MediaKind } from '@/types/media.types' + +/** + * Get icon name for a given media kind + */ +export function kindToIcon(kind: MediaKind): string { + const iconMap: Record = { + video: 'pi pi-video', + webm: 'pi pi-video', + webp: 'pi pi-image', + gif: 'pi pi-images', + audio: 'pi pi-volume-up', + image: 'pi pi-image', + pose: 'pi pi-user', + text: 'pi pi-file-edit', + other: 'pi pi-file' + } + + return iconMap[kind] || 'pi pi-file' +} diff --git a/tests-ui/tests/components/assetLibrary/QueueAssetCard.test.ts b/tests-ui/tests/components/assetLibrary/QueueAssetCard.test.ts new file mode 100644 index 0000000000..90764f4454 --- /dev/null +++ b/tests-ui/tests/components/assetLibrary/QueueAssetCard.test.ts @@ -0,0 +1,204 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import QueueAssetCard from '@/components/assetLibrary/QueueAssetCard.vue' +import type { AssetMeta } from '@/types/media.types' + +describe('QueueAssetCard', () => { + const mockAsset: AssetMeta = { + id: 'test-asset-1', + name: 'test-image.png', + kind: 'image', + size: 1048576, + timestamp: Date.now(), + thumbnailUrl: 'test-thumbnail.jpg', + dimensions: { + width: 1920, + height: 1080 + } + } + + it('renders loading skeleton when loading prop is true', () => { + const wrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: mockAsset, + loading: true + } + }) + + expect(wrapper.find('.animate-pulse').exists()).toBe(true) + expect(wrapper.find('h3').exists()).toBe(false) + }) + + it('renders error state when error prop is provided', () => { + const errorMessage = 'Failed to load asset' + const wrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: mockAsset, + error: errorMessage + } + }) + + expect(wrapper.find('.pi-exclamation-triangle').exists()).toBe(true) + expect(wrapper.text()).toContain(errorMessage) + }) + + it('renders asset information correctly', () => { + const wrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: mockAsset + } + }) + + expect(wrapper.find('h3').text()).toBe(mockAsset.name) + expect(wrapper.text()).toContain('1 MB') + expect(wrapper.text()).toContain('1920×1080') + }) + + it('shows job ID and copy button only in output context', () => { + const assetWithJob = { + ...mockAsset, + jobId: 'job-123' + } + + // Test input context - no job ID + const inputWrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: assetWithJob + } + }) + expect(inputWrapper.text()).not.toContain('Job:') + expect(inputWrapper.find('.pi-copy').exists()).toBe(false) + + // Test output context - has job ID + const outputWrapper = mount(QueueAssetCard, { + props: { + context: 'output', + asset: assetWithJob + } + }) + expect(outputWrapper.text()).toContain('Job: job-123') + expect(outputWrapper.find('.pi-copy').exists()).toBe(true) + }) + + it('emits download event when download button is clicked', async () => { + const wrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: mockAsset + } + }) + + const downloadBtn = wrapper.find('[aria-label="Download image"]') + await downloadBtn.trigger('click') + + expect(wrapper.emitted('download')).toBeTruthy() + expect(wrapper.emitted('download')![0]).toEqual([mockAsset.id]) + }) + + it('emits copyJobId event when copy button is clicked in output context', async () => { + const assetWithJob = { + ...mockAsset, + jobId: 'job-456' + } + + const wrapper = mount(QueueAssetCard, { + props: { + context: 'output', + asset: assetWithJob + } + }) + + const copyBtn = wrapper + .find('.pi-copy') + .element.closest('button') as HTMLButtonElement + await copyBtn.click() + + expect(wrapper.emitted('copyJobId')).toBeTruthy() + expect(wrapper.emitted('copyJobId')![0]).toEqual(['job-456']) + }) + + it('emits openDetail event when card is clicked', async () => { + const wrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: mockAsset + } + }) + + await wrapper.find('[role="button"]').trigger('click') + + expect(wrapper.emitted('openDetail')).toBeTruthy() + expect(wrapper.emitted('openDetail')![0]).toEqual([mockAsset.id]) + }) + + it('handles keyboard activation with Enter and Space keys', async () => { + const wrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: mockAsset + } + }) + + const card = wrapper.find('[role="button"]') + + await card.trigger('keydown.enter') + expect(wrapper.emitted('openDetail')).toHaveLength(1) + + await card.trigger('keydown.space') + expect(wrapper.emitted('openDetail')).toHaveLength(2) + }) + + it('applies dense mode correctly', () => { + const wrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: mockAsset, + dense: true + } + }) + + // CardContainer component applies the aspect ratio based on the ratio prop + const card = wrapper.findComponent({ name: 'CardContainer' }) + expect(card.props('ratio')).toBe('smallSquare') + }) + + it('shows fallback icon when thumbnailUrl is not provided', () => { + const assetWithoutThumbnail = { + ...mockAsset, + thumbnailUrl: undefined + } + + const wrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: assetWithoutThumbnail + } + }) + + // Should show icon when no thumbnail + expect(wrapper.find('.pi-image, .pi-file').exists()).toBe(true) + }) + + it('formats audio duration correctly', () => { + const audioAsset: AssetMeta = { + ...mockAsset, + kind: 'audio', + duration: 125, + dimensions: undefined + } + + const wrapper = mount(QueueAssetCard, { + props: { + context: 'input', + asset: audioAsset + } + }) + + expect(wrapper.text()).toContain('2:05') + }) +}) From 522a99ecdc1bb94dddb5e11cf72bdcb4cfcf2a71 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Wed, 1 Oct 2025 21:21:25 +0900 Subject: [PATCH 2/3] [chore] Remove unused formatUtil.ts file --- src/utils/formatUtil.ts | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 src/utils/formatUtil.ts diff --git a/src/utils/formatUtil.ts b/src/utils/formatUtil.ts deleted file mode 100644 index cf703759df..0000000000 --- a/src/utils/formatUtil.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Format bytes to human-readable size - * Copied from packages/shared-frontend-utils/src/formatUtil.ts - */ -export function formatSize(value?: number) { - if (value === null || value === undefined) { - return '-' - } - - const bytes = value - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` -} From 97df22f1fb6f0729a1b98f8ce6f9a1680412fe66 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 1 Oct 2025 12:29:41 +0000 Subject: [PATCH 3/3] [auto-fix] Apply ESLint and Prettier fixes --- src/components/assetLibrary/cards/QueueImageCard.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/assetLibrary/cards/QueueImageCard.vue b/src/components/assetLibrary/cards/QueueImageCard.vue index cda339f99d..eeca19cedd 100644 --- a/src/components/assetLibrary/cards/QueueImageCard.vue +++ b/src/components/assetLibrary/cards/QueueImageCard.vue @@ -18,7 +18,9 @@