mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 01:50:08 +00:00
Add MediaAssetCard presentation components (#5878)
## Summary
Implements a comprehensive media asset card component system for the
Asset Manager sidebar, enabling display and interaction with various
media types (images, videos, audio, and 3D models).
## Changes
### New Components
- **MediaAssetCard**: Main card component for displaying media assets
- **Media type-specific components**: Specialized display logic for each
media type
- MediaImageTop/Bottom
- MediaVideoTop/Bottom
- MediaAudioTop/Bottom
- Media3DTop/Bottom
- **MediaAssetActions**: Top-left action buttons (delete, download, more
options)
- **MediaAssetMoreMenu**: Dropdown menu for additional actions
- **SquareChip**: Chip component for displaying duration and file format
with dark/light variants
- **MediaAssetButtonDivider**: Visual separator for button groups
### Features
- **Video playback**: Autoplay with native video controls
- Dynamic duration chip positioning based on control visibility
- Hides overlays when video is playing
- **Audio playback**: Audio icon with HTML5 audio element
- Duration chip with consistent positioning
- **3D model support**: Icon display for 3D assets
- **Selection state**: Proper hover and selected state handling with CSS
priority fixes
### Architecture Improvements
- **Domain-Driven Design structure**: Organized under
`src/platform/mediaAsset/` following DDD principles
- **Provide/Inject pattern**: Eliminates props drilling with
MediaAssetKey InjectionKey
- **Composable pattern**: `useMediaAssetActions` manages all action
handlers
- **Type safety**: Comprehensive TypeScript types for media assets and
actions
### UI/UX Enhancements
- **CardTop component**: Added custom class props for slot positioning
- **SquareChip component**: Backdrop blur effects with variant system
- **Lazy loading**: Image optimization with LazyImage component
- **Responsive states**: Loading, selected, and hover states
### Utilities
- **formatDuration**: Converts milliseconds to human-readable format
(45s, 1m 23s, 1h 2m)
## Testing
- Comprehensive Storybook stories for all media types
- Grid layout examples
- Loading and selected state demonstrations
## File Structure
```
src/platform/assets/
├── components/
│ ├── MediaAssetCard.vue
│ ├── MediaAssetCard.stories.ts
│ ├── MediaAssetActions.vue
│ ├── MediaAssetMoreMenu.vue
│ ├── MediaAssetButtonDivider.vue
│ ├── MediaImageTop.vue
│ ├── MediaImageBottom.vue
│ ├── MediaVideoTop.vue
│ ├── MediaVideoBottom.vue
│ ├── MediaAudioTop.vue
│ ├── MediaAudioBottom.vue
│ ├── Media3DTop.vue
│ └── Media3DBottom.vue
├── composables/
│ └── useMediaAssetActions.ts
└── schemas/
└── mediaAssetSchema.ts
```
## Screenshots
[media_asset_record.webm](https://github.com/user-attachments/assets/d13b5cc0-a262-4850-bb81-ca1daa0dd969)
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
1
packages/design-system/src/icons/play.svg
Normal file
1
packages/design-system/src/icons/play.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3" d="m4 2 9.333 6L4 14V2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@@ -82,7 +82,7 @@ export function formatSize(value?: number) {
|
||||
* - filename: 'file'
|
||||
* - suffix: 'txt'
|
||||
*/
|
||||
function getFilenameDetails(fullFilename: string) {
|
||||
export function getFilenameDetails(fullFilename: string) {
|
||||
if (fullFilename.includes('.')) {
|
||||
return {
|
||||
filename: fullFilename.split('.').slice(0, -1).join('.'),
|
||||
@@ -451,3 +451,26 @@ export function stringToLocale(locale: string): SupportedLocale {
|
||||
? (locale as SupportedLocale)
|
||||
: 'en'
|
||||
}
|
||||
|
||||
export function formatDuration(milliseconds: number): string {
|
||||
if (!milliseconds || milliseconds < 0) return '0s'
|
||||
|
||||
const totalSeconds = Math.floor(milliseconds / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const remainingSeconds = Math.floor(totalSeconds % 60)
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
if (remainingSeconds > 0 || parts.length === 0) {
|
||||
parts.push(`${remainingSeconds}s`)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const iconGroupClasses = cn(
|
||||
'outline-hidden border-none p-0 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-700',
|
||||
'text-neutral-950 dark-theme:text-white',
|
||||
'transition-all duration-200',
|
||||
'cursor-pointer'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<IconButton @click="toggle">
|
||||
<i class="icon-[lucide--more-vertical] text-sm" />
|
||||
<IconButton :size="size" :type="type" @click="toggle">
|
||||
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
|
||||
<i v-else class="icon-[lucide--more-vertical] text-sm" />
|
||||
</IconButton>
|
||||
|
||||
<Popover
|
||||
@@ -13,6 +14,8 @@
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
@@ -25,12 +28,28 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
interface MoreButtonProps extends BaseButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
isVertical = false
|
||||
} = defineProps<MoreButtonProps>()
|
||||
|
||||
defineEmits<{
|
||||
menuOpened: []
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
@@ -45,7 +64,7 @@ const pt = computed(() => ({
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg',
|
||||
'mt-1 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800',
|
||||
'text-neutral dark-theme:text-white',
|
||||
'shadow-lg',
|
||||
|
||||
@@ -11,7 +11,15 @@ import CardTop from './CardTop.vue'
|
||||
|
||||
interface CardStoryArgs {
|
||||
// CardContainer props
|
||||
containerRatio: 'square' | 'portrait' | 'tallPortrait'
|
||||
containerSize: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
|
||||
variant: 'default' | 'ghost' | 'outline'
|
||||
rounded: 'none' | 'sm' | 'lg' | 'xl'
|
||||
customAspectRatio?: string
|
||||
hasBorder: boolean
|
||||
hasBackground: boolean
|
||||
hasShadow: boolean
|
||||
hasCursor: boolean
|
||||
customClass: string
|
||||
maxWidth: number
|
||||
minWidth: number
|
||||
|
||||
@@ -44,10 +52,44 @@ interface CardStoryArgs {
|
||||
const meta: Meta<CardStoryArgs> = {
|
||||
title: 'Components/Card/Card',
|
||||
argTypes: {
|
||||
containerRatio: {
|
||||
containerSize: {
|
||||
control: 'select',
|
||||
options: ['square', 'portrait', 'tallPortrait'],
|
||||
description: 'Card container aspect ratio'
|
||||
options: ['mini', 'compact', 'regular', 'portrait', 'tall'],
|
||||
description: 'Card container size preset'
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'ghost', 'outline'],
|
||||
description: 'Card visual variant'
|
||||
},
|
||||
rounded: {
|
||||
control: 'select',
|
||||
options: ['none', 'sm', 'lg', 'xl'],
|
||||
description: 'Border radius size'
|
||||
},
|
||||
customAspectRatio: {
|
||||
control: 'text',
|
||||
description: 'Custom aspect ratio (e.g., "16/9")'
|
||||
},
|
||||
hasBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Add border styling'
|
||||
},
|
||||
hasBackground: {
|
||||
control: 'boolean',
|
||||
description: 'Add background styling'
|
||||
},
|
||||
hasShadow: {
|
||||
control: 'boolean',
|
||||
description: 'Add shadow styling'
|
||||
},
|
||||
hasCursor: {
|
||||
control: 'boolean',
|
||||
description: 'Add cursor pointer'
|
||||
},
|
||||
customClass: {
|
||||
control: 'text',
|
||||
description: 'Additional custom CSS classes'
|
||||
},
|
||||
topRatio: {
|
||||
control: 'select',
|
||||
@@ -149,8 +191,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
template: `
|
||||
<div class="min-h-screen">
|
||||
<CardContainer
|
||||
:ratio="args.containerRatio"
|
||||
class="max-w-[320px] mx-auto"
|
||||
:size="args.containerSize"
|
||||
:variant="args.variant"
|
||||
:rounded="args.rounded"
|
||||
:custom-aspect-ratio="args.customAspectRatio"
|
||||
:has-border="args.hasBorder"
|
||||
:has-background="args.hasBackground"
|
||||
:has-shadow="args.hasShadow"
|
||||
:has-cursor="args.hasCursor"
|
||||
:class="args.customClass || 'max-w-[320px] mx-auto'"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop :ratio="args.topRatio">
|
||||
@@ -205,7 +254,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3 bg-neutral-100">
|
||||
<CardBottom>
|
||||
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
|
||||
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
@@ -218,7 +267,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
export const Default: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
containerSize: 'portrait',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -243,7 +300,15 @@ export const Default: Story = {
|
||||
export const SquareCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -268,7 +333,15 @@ export const SquareCard: Story = {
|
||||
export const TallPortraitCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
containerSize: 'tall',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
@@ -293,7 +366,15 @@ export const TallPortraitCard: Story = {
|
||||
export const ImageCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
containerSize: 'portrait',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -314,10 +395,50 @@ export const ImageCard: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const MiniCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'mini',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: false,
|
||||
title: 'Mini Asset',
|
||||
description: '',
|
||||
backgroundColor: '#06b6d4',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Asset'],
|
||||
showFileSize: true,
|
||||
fileSize: '124 KB',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const MinimalCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
@@ -338,10 +459,209 @@ export const MinimalCard: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const GhostVariant: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'compact',
|
||||
variant: 'ghost',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Workflow Template',
|
||||
description: 'Ghost variant for workflow templates',
|
||||
backgroundColor: '#10b981',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Template'],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const OutlineVariant: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'outline',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Outline Card',
|
||||
description: 'Card with outline variant styling',
|
||||
backgroundColor: '#f59e0b',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomAspectRatio: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
customAspectRatio: '16/9',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: false,
|
||||
title: 'Wide Format Card',
|
||||
description: '',
|
||||
backgroundColor: '#8b5cf6',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Wide'],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const RoundedNone: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'none',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Sharp Corners',
|
||||
description: 'Card with no border radius',
|
||||
backgroundColor: '#dc2626',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const RoundedXL: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'xl',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Extra Rounded',
|
||||
description: 'Card with extra large border radius',
|
||||
backgroundColor: '#059669',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const NoStylesCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: false,
|
||||
hasBackground: false,
|
||||
hasShadow: false,
|
||||
hasCursor: true,
|
||||
customClass: 'bg-gradient-to-br from-blue-500 to-purple-600',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Custom Styled Card',
|
||||
description: 'Card with all default styles removed and custom gradient',
|
||||
backgroundColor: 'transparent',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const FullFeaturedCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
containerSize: 'tall',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
|
||||
@@ -8,26 +8,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { ratio = 'square', type } = defineProps<{
|
||||
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
|
||||
type?: string
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
size = 'regular',
|
||||
variant = 'default',
|
||||
rounded = 'md',
|
||||
customAspectRatio,
|
||||
hasBorder = true,
|
||||
hasBackground = true,
|
||||
hasShadow = true,
|
||||
hasCursor = true,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
size?: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
|
||||
variant?: 'default' | 'ghost' | 'outline'
|
||||
rounded?: 'none' | 'md' | 'lg' | 'xl'
|
||||
customAspectRatio?: string
|
||||
hasBorder?: boolean
|
||||
hasBackground?: boolean
|
||||
hasShadow?: boolean
|
||||
hasCursor?: boolean
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
// Base structure classes
|
||||
const structureClasses = 'flex flex-col overflow-hidden'
|
||||
|
||||
// Rounded corners
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
md: 'rounded',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl'
|
||||
} as const
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const baseClasses =
|
||||
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||
|
||||
if (type === 'workflow-template-card') {
|
||||
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
|
||||
// Variant styles
|
||||
const variantClasses = {
|
||||
default: cn(
|
||||
hasBackground && 'bg-white dark-theme:bg-zinc-800',
|
||||
hasBorder && 'border border-zinc-200 dark-theme:border-zinc-700',
|
||||
hasShadow && 'shadow-sm',
|
||||
hasCursor && 'cursor-pointer'
|
||||
),
|
||||
ghost: cn(
|
||||
hasCursor && 'cursor-pointer',
|
||||
'p-2 transition-colors duration-200'
|
||||
),
|
||||
outline: cn(
|
||||
hasBorder && 'border-2 border-zinc-300 dark-theme:border-zinc-600',
|
||||
hasCursor && 'cursor-pointer',
|
||||
'hover:border-zinc-400 dark-theme:hover:border-zinc-500 transition-colors'
|
||||
)
|
||||
}
|
||||
|
||||
const ratioClasses = {
|
||||
smallSquare: 'aspect-240/311',
|
||||
square: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tallPortrait: 'aspect-256/353'
|
||||
}
|
||||
// Size/aspect ratio
|
||||
const aspectRatio = customAspectRatio
|
||||
? `aspect-[${customAspectRatio}]`
|
||||
: {
|
||||
mini: 'aspect-100/120',
|
||||
compact: 'aspect-240/311',
|
||||
regular: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tall: 'aspect-256/353'
|
||||
}[size]
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
return cn(
|
||||
structureClasses,
|
||||
roundedClasses[rounded],
|
||||
variantClasses[variant],
|
||||
aspectRatio,
|
||||
customClass
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,31 +2,27 @@
|
||||
<div :class="topStyle">
|
||||
<slot class="absolute top-0 left-0 h-full w-full"></slot>
|
||||
|
||||
<div
|
||||
v-if="slots['top-left']"
|
||||
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
|
||||
>
|
||||
<div v-if="slots['top-left']" :class="slotClasses['top-left']">
|
||||
<slot name="top-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['top-right']"
|
||||
class="absolute top-2 right-2 flex flex-wrap justify-end gap-2"
|
||||
>
|
||||
<div v-if="slots['top-right']" :class="slotClasses['top-right']">
|
||||
<slot name="top-right"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-left']"
|
||||
class="absolute bottom-2 left-2 flex flex-wrap justify-start gap-2"
|
||||
>
|
||||
<div v-if="slots['center-left']" :class="slotClasses['center-left']">
|
||||
<slot name="center-left"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="slots['center-right']" :class="slotClasses['center-right']">
|
||||
<slot name="center-right"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="slots['bottom-left']" :class="slotClasses['bottom-left']">
|
||||
<slot name="bottom-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-right']"
|
||||
class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-2"
|
||||
>
|
||||
<div v-if="slots['bottom-right']" :class="slotClasses['bottom-right']">
|
||||
<slot name="bottom-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,10 +31,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
const {
|
||||
ratio = 'square',
|
||||
topLeftClass,
|
||||
topRightClass,
|
||||
centerLeftClass,
|
||||
centerRightClass,
|
||||
bottomLeftClass,
|
||||
bottomRightClass
|
||||
} = defineProps<{
|
||||
ratio?: 'square' | 'landscape'
|
||||
topLeftClass?: string
|
||||
topRightClass?: string
|
||||
centerLeftClass?: string
|
||||
centerRightClass?: string
|
||||
bottomLeftClass?: string
|
||||
bottomRightClass?: string
|
||||
}>()
|
||||
|
||||
const topStyle = computed(() => {
|
||||
@@ -51,4 +63,26 @@ const topStyle = computed(() => {
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
})
|
||||
|
||||
// Get default classes for each slot position
|
||||
const defaultSlotClasses = {
|
||||
'top-left': 'absolute top-2 left-2 flex flex-wrap justify-start gap-2',
|
||||
'top-right': 'absolute top-2 right-2 flex flex-wrap justify-end gap-2',
|
||||
'center-left':
|
||||
'absolute top-1/2 left-2 flex -translate-y-1/2 flex-wrap justify-start gap-2',
|
||||
'center-right':
|
||||
'absolute top-1/2 right-2 flex -translate-y-1/2 flex-wrap justify-end gap-2',
|
||||
'bottom-left': 'absolute bottom-2 left-2 flex flex-wrap justify-start gap-2',
|
||||
'bottom-right': 'absolute right-2 bottom-2 flex flex-wrap justify-end gap-2'
|
||||
}
|
||||
|
||||
// Compute all slot classes once and cache them
|
||||
const slotClasses = computed(() => ({
|
||||
'top-left': cn(defaultSlotClasses['top-left'], topLeftClass),
|
||||
'top-right': cn(defaultSlotClasses['top-right'], topRightClass),
|
||||
'center-left': cn(defaultSlotClasses['center-left'], centerLeftClass),
|
||||
'center-right': cn(defaultSlotClasses['center-right'], centerRightClass),
|
||||
'bottom-left': cn(defaultSlotClasses['bottom-left'], bottomLeftClass),
|
||||
'bottom-right': cn(defaultSlotClasses['bottom-right'], bottomRightClass)
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex shrink-0 items-center justify-center gap-1 rounded bg-[#D9D9D966]/40 px-2 py-1 text-xs font-bold text-white/90"
|
||||
>
|
||||
<slot name="icon" class="text-xs text-white/90"></slot>
|
||||
<div :class="chipClasses">
|
||||
<slot name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const { label } = defineProps<{
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { label, variant = 'dark' } = defineProps<{
|
||||
label: string
|
||||
variant?: 'dark' | 'light'
|
||||
}>()
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
|
||||
|
||||
const variantStyles = {
|
||||
dark: 'bg-zinc-500/40 text-white/90',
|
||||
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
|
||||
}
|
||||
|
||||
const chipClasses = computed(() => {
|
||||
return cn(baseClasses, variantStyles[variant])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative flex h-full w-full items-center justify-center overflow-hidden"
|
||||
:class="containerClass"
|
||||
>
|
||||
<Skeleton
|
||||
v-if="!isImageLoaded"
|
||||
@@ -41,17 +42,20 @@ import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
import type { ClassValue } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
alt = '',
|
||||
containerClass = '',
|
||||
imageClass = '',
|
||||
imageStyle,
|
||||
rootMargin = '300px'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
imageClass?: string | string[] | Record<string, boolean>
|
||||
containerClass?: ClassValue
|
||||
imageClass?: ClassValue
|
||||
imageStyle?: Record<string, any>
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
@@ -141,8 +141,10 @@
|
||||
<CardContainer
|
||||
v-for="n in isLoading ? 12 : 0"
|
||||
:key="`initial-skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
@@ -172,9 +174,11 @@
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
@mouseenter="hoveredTemplate = template.name"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="onLoadWorkflow(template)"
|
||||
@@ -316,8 +320,10 @@
|
||||
<CardContainer
|
||||
v-for="n in isLoadingMore ? 6 : 0"
|
||||
:key="`skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<template #content>
|
||||
<!-- Card Examples -->
|
||||
<div :style="gridStyle">
|
||||
<CardContainer v-for="i in 100" :key="i" ratio="square">
|
||||
<CardContainer v-for="i in 100" :key="i" size="regular">
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
|
||||
29
src/platform/assets/components/Media3DBottom.vue
Normal file
29
src/platform/assets/components/Media3DBottom.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
16
src/platform/assets/components/Media3DTop.vue
Normal file
16
src/platform/assets/components/Media3DTop.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--box] text-3xl text-zinc-600 dark-theme:text-zinc-200"
|
||||
/>
|
||||
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
|
||||
$t('3D Model')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
50
src/platform/assets/components/MediaAssetActions.vue
Normal file
50
src/platform/assets/components/MediaAssetActions.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<IconGroup>
|
||||
<IconButton size="sm" @click="handleDelete">
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
<IconButton size="sm" @click="handleDownload">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</IconButton>
|
||||
<MoreButton
|
||||
size="sm"
|
||||
@menu-opened="emit('menuStateChanged', true)"
|
||||
@menu-closed="emit('menuStateChanged', false)"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetMoreMenu :close="close" />
|
||||
</template>
|
||||
</MoreButton>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
menuStateChanged: [isOpen: boolean]
|
||||
}>()
|
||||
|
||||
const { asset } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const handleDelete = () => {
|
||||
if (asset.value) {
|
||||
actions.deleteAsset(asset.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<div class="h-[1px] bg-neutral-200 dark-theme:bg-neutral-700"></div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
318
src/platform/assets/components/MediaAssetCard.stories.ts
Normal file
318
src/platform/assets/components/MediaAssetCard.stories.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetCard from './MediaAssetCard.vue'
|
||||
|
||||
const meta: Meta<typeof MediaAssetCard> = {
|
||||
title: 'AssetLibrary/MediaAssetCard',
|
||||
component: MediaAssetCard,
|
||||
argTypes: {
|
||||
context: {
|
||||
control: 'select',
|
||||
options: ['input', 'output']
|
||||
},
|
||||
loading: {
|
||||
control: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Public sample media URLs
|
||||
const SAMPLE_MEDIA = {
|
||||
image1: 'https://i.imgur.com/OB0y6MR.jpg',
|
||||
image2: 'https://i.imgur.com/CzXTtJV.jpg',
|
||||
image3: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
|
||||
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',
|
||||
duration: 3345,
|
||||
size: 2048576,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image1,
|
||||
dimensions: {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
|
||||
export const ImageAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'output', outputCount: 3 },
|
||||
asset: sampleAsset,
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
|
||||
export const VideoAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-2',
|
||||
name: 'Big_Buck_Bunny.mp4',
|
||||
kind: 'video',
|
||||
size: 10485760,
|
||||
duration: 13425,
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
|
||||
src: SAMPLE_MEDIA.video, // Actual video file
|
||||
dimensions: {
|
||||
width: 1280,
|
||||
height: 720
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Model3DAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-3',
|
||||
name: 'Asset-3d-model.glb',
|
||||
kind: '3D',
|
||||
size: 7340032,
|
||||
src: '',
|
||||
dimensions: undefined,
|
||||
duration: 18023
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const AudioAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-3',
|
||||
name: 'SoundHelix-Song.mp3',
|
||||
kind: 'audio',
|
||||
size: 5242880,
|
||||
src: SAMPLE_MEDIA.audio,
|
||||
dimensions: undefined,
|
||||
duration: 23180
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoadingState: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: sampleAsset,
|
||||
loading: true
|
||||
}
|
||||
}
|
||||
|
||||
export const LongFileName: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SelectedState: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'output', outputCount: 2 },
|
||||
asset: sampleAsset,
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WebMVideo: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
id: 'asset-webm',
|
||||
name: 'animated-clip.webm',
|
||||
kind: 'video',
|
||||
size: 3145728,
|
||||
created_at: Date.now().toString(),
|
||||
preview_url: SAMPLE_MEDIA.image1, // Poster image
|
||||
src: 'https://www.w3schools.com/html/movie.mp4', // Actual video
|
||||
duration: 620,
|
||||
dimensions: {
|
||||
width: 640,
|
||||
height: 360
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GifAnimation: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
id: 'asset-gif',
|
||||
name: 'animation.gif',
|
||||
kind: 'image',
|
||||
size: 1572864,
|
||||
duration: 1345,
|
||||
created_at: Date.now().toString(),
|
||||
src: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
|
||||
dimensions: {
|
||||
width: 480,
|
||||
height: 270
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GridLayout: Story = {
|
||||
render: () => ({
|
||||
components: { MediaAssetCard },
|
||||
setup() {
|
||||
const assets: AssetMeta[] = [
|
||||
{
|
||||
id: 'grid-1',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image1,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-2',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image2,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-3',
|
||||
name: 'video-file.mp4',
|
||||
kind: 'video',
|
||||
size: 10485760,
|
||||
duration: 13425,
|
||||
created_at: Date.now().toString(),
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
|
||||
src: SAMPLE_MEDIA.video, // Actual video
|
||||
dimensions: { width: 1280, height: 720 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-4',
|
||||
name: 'audio-file.mp3',
|
||||
kind: 'audio',
|
||||
size: 5242880,
|
||||
duration: 180,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.audio,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-5',
|
||||
name: 'animation.gif',
|
||||
kind: 'image',
|
||||
size: 3145728,
|
||||
duration: 1345,
|
||||
created_at: Date.now().toString(),
|
||||
src: 'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
|
||||
dimensions: { width: 480, height: 360 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-6',
|
||||
name: 'Asset-3d-model.glb',
|
||||
kind: '3D',
|
||||
size: 7340032,
|
||||
src: '',
|
||||
dimensions: undefined,
|
||||
duration: 18023,
|
||||
created_at: Date.now().toString(),
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-7',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image3,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
return { assets }
|
||||
},
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; padding: 16px;">
|
||||
<MediaAssetCard
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
:context="{ type: Math.random() > 0.5 ? 'input' : 'output', outputCount: Math.floor(Math.random() * 5) }"
|
||||
:asset="asset"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
233
src/platform/assets/components/MediaAssetCard.vue
Normal file
233
src/platform/assets/components/MediaAssetCard.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<CardContainer
|
||||
ref="cardContainerRef"
|
||||
role="button"
|
||||
:aria-label="
|
||||
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
|
||||
"
|
||||
:tabindex="loading ? -1 : 0"
|
||||
size="mini"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:class="containerClasses"
|
||||
@click="handleCardClick"
|
||||
@keydown.enter="handleCardClick"
|
||||
@keydown.space.prevent="handleCardClick"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop
|
||||
ratio="square"
|
||||
:bottom-left-class="durationChipClasses"
|
||||
:bottom-right-class="durationChipClasses"
|
||||
>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div
|
||||
class="h-full w-full animate-pulse rounded-lg bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<component
|
||||
:is="getTopComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
@view="actions.viewAsset(asset!.id)"
|
||||
@download="actions.downloadAsset(asset!.id)"
|
||||
@play="actions.playAsset(asset!.id)"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Actions overlay (top-left) - show on hover or when menu is open, but not when video is playing -->
|
||||
<template v-if="showActionsOverlay" #top-left>
|
||||
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
|
||||
</template>
|
||||
|
||||
<!-- Zoom button (top-right) - show on hover, but not when video is playing -->
|
||||
<template v-if="showZoomOverlay" #top-right>
|
||||
<IconButton size="sm" @click="actions.viewAsset(asset!.id)">
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<!-- Duration/Format chips (bottom-left) - hide when video is playing -->
|
||||
<template v-if="showDurationChips" #bottom-left>
|
||||
<SquareChip variant="light" :label="formattedDuration" />
|
||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||
</template>
|
||||
|
||||
<!-- Output count (bottom-right) - hide when video is playing -->
|
||||
<template v-if="showOutputCount" #bottom-right>
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:label="context?.outputCount?.toString() ?? '0'"
|
||||
@click="actions.openMoreOutputs(asset?.id || '')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div class="flex flex-col items-center justify-between gap-1">
|
||||
<div
|
||||
class="h-4 w-2/3 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
<div
|
||||
class="h-3 w-1/2 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<component
|
||||
:is="getBottomComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
/>
|
||||
</template>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import { formatDuration } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type {
|
||||
AssetContext,
|
||||
AssetMeta,
|
||||
MediaKind
|
||||
} from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetActions from './MediaAssetActions.vue'
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
|
||||
},
|
||||
bottom: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
|
||||
}
|
||||
}
|
||||
|
||||
function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
}
|
||||
|
||||
function getBottomComponent(kind: MediaKind) {
|
||||
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
|
||||
}
|
||||
|
||||
const { context, asset, loading, selected } = defineProps<{
|
||||
context: AssetContext
|
||||
asset?: AssetMeta
|
||||
loading?: boolean
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const cardContainerRef = ref<HTMLElement>()
|
||||
|
||||
const isVideoPlaying = ref(false)
|
||||
const isMenuOpen = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
|
||||
const isHovered = useElementHover(cardContainerRef)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
provide(MediaAssetKey, {
|
||||
asset: toRef(() => asset),
|
||||
context: toRef(() => context),
|
||||
isVideoPlaying,
|
||||
showVideoControls
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
return cn(
|
||||
'gap-1',
|
||||
selected
|
||||
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
|
||||
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
|
||||
)
|
||||
})
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
if (!asset?.duration) return ''
|
||||
return formatDuration(asset.duration)
|
||||
})
|
||||
|
||||
const fileFormat = computed(() => {
|
||||
if (!asset?.name) return ''
|
||||
const parts = asset.name.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
|
||||
})
|
||||
|
||||
const durationChipClasses = computed(() => {
|
||||
if (asset?.kind === 'audio') {
|
||||
return '-translate-y-11'
|
||||
}
|
||||
if (asset?.kind === 'video' && showVideoControls.value) {
|
||||
return '-translate-y-16'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const showHoverActions = computed(() => {
|
||||
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
|
||||
})
|
||||
|
||||
const showZoomButton = computed(() => {
|
||||
return asset?.kind === 'image' || asset?.kind === '3D'
|
||||
})
|
||||
|
||||
const showActionsOverlay = computed(() => {
|
||||
return showHoverActions.value && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const showZoomOverlay = computed(() => {
|
||||
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const showDurationChips = computed(() => {
|
||||
return !loading && asset?.duration && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const showOutputCount = computed(() => {
|
||||
return !loading && context?.outputCount && !isVideoPlaying.value
|
||||
})
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (asset) {
|
||||
actions.selectAsset(asset)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
158
src/platform/assets/components/MediaAssetMoreMenu.vue
Normal file
158
src/platform/assets/components/MediaAssetMoreMenu.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Inspect asset"
|
||||
@click="handleInspect"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Add to current workflow"
|
||||
@click="handleAddToWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[comfy--node] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Download"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider />
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Open as workflow in new tab"
|
||||
@click="handleOpenWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[comfy--workflow] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Export workflow"
|
||||
@click="handleExportWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--file-output] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Copy job ID"
|
||||
@click="handleCopyJobId"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider />
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Delete"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
||||
|
||||
const { close } = defineProps<{
|
||||
close: () => void
|
||||
}>()
|
||||
|
||||
const { asset, context } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const showWorkflowOptions = computed(() => {
|
||||
return context.value.type
|
||||
})
|
||||
|
||||
const handleInspect = () => {
|
||||
if (asset.value) {
|
||||
actions.viewAsset(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleAddToWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.addWorkflow(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleOpenWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.openWorkflow(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleExportWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.exportWorkflow(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleCopyJobId = () => {
|
||||
if (asset.value) {
|
||||
actions.copyAssetUrl(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (asset.value) {
|
||||
actions.deleteAsset(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
30
src/platform/assets/components/MediaAudioBottom.vue
Normal file
30
src/platform/assets/components/MediaAudioBottom.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
28
src/platform/assets/components/MediaAudioTop.vue
Normal file
28
src/platform/assets/components/MediaAudioTop.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--music] text-3xl text-zinc-600 dark-theme:text-zinc-200"
|
||||
/>
|
||||
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
|
||||
$t('Audio')
|
||||
}}</span>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
class="absolute bottom-0 left-0 w-full p-2"
|
||||
:src="asset.src"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
</script>
|
||||
30
src/platform/assets/components/MediaImageBottom.vue
Normal file
30
src/platform/assets/components/MediaImageBottom.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
27
src/platform/assets/components/MediaImageTop.vue
Normal file
27
src/platform/assets/components/MediaImageTop.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<LazyImage
|
||||
v-if="asset.src"
|
||||
:src="asset.src"
|
||||
:alt="asset.name"
|
||||
:container-class="'aspect-square'"
|
||||
:image-class="'w-full h-full object-cover'"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i class="pi pi-image text-3xl text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
</script>
|
||||
30
src/platform/assets/components/MediaVideoBottom.vue
Normal file
30
src/platform/assets/components/MediaVideoBottom.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
57
src/platform/assets/components/MediaVideoTop.vue
Normal file
57
src/platform/assets/components/MediaVideoTop.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden rounded bg-black"
|
||||
@mouseenter="showControls = true"
|
||||
@mouseleave="showControls = false"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:controls="showControls"
|
||||
preload="none"
|
||||
:poster="asset.preview_url"
|
||||
class="relative h-full w-full object-contain"
|
||||
@click.stop
|
||||
@play="onVideoPlay"
|
||||
@pause="onVideoPause"
|
||||
>
|
||||
<source :src="asset.src || ''" />
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [assetId: string]
|
||||
videoPlayingStateChanged: [isPlaying: boolean]
|
||||
videoControlsChanged: [showControls: boolean]
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
const showControls = ref(true)
|
||||
|
||||
watch(showControls, (controlsVisible) => {
|
||||
emit('videoControlsChanged', controlsVisible)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
emit('videoControlsChanged', showControls.value)
|
||||
})
|
||||
|
||||
const onVideoPlay = () => {
|
||||
showControls.value = true
|
||||
emit('videoPlayingStateChanged', true)
|
||||
}
|
||||
|
||||
const onVideoPause = () => {
|
||||
emit('videoPlayingStateChanged', false)
|
||||
}
|
||||
</script>
|
||||
62
src/platform/assets/composables/useMediaAssetActions.ts
Normal file
62
src/platform/assets/composables/useMediaAssetActions.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable no-console */
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const selectAsset = (asset: AssetMeta) => {
|
||||
console.log('Asset selected:', asset)
|
||||
}
|
||||
|
||||
const viewAsset = (assetId: string) => {
|
||||
console.log('Viewing asset:', assetId)
|
||||
}
|
||||
|
||||
const downloadAsset = (assetId: string) => {
|
||||
console.log('Downloading asset:', assetId)
|
||||
}
|
||||
|
||||
const deleteAsset = (assetId: string) => {
|
||||
console.log('Deleting asset:', assetId)
|
||||
}
|
||||
|
||||
const playAsset = (assetId: string) => {
|
||||
console.log('Playing asset:', assetId)
|
||||
}
|
||||
|
||||
const copyAssetUrl = (assetId: string) => {
|
||||
console.log('Copy asset URL:', assetId)
|
||||
}
|
||||
|
||||
const copyJobId = (jobId: string) => {
|
||||
console.log('Copy job ID:', jobId)
|
||||
}
|
||||
|
||||
const addWorkflow = (assetId: string) => {
|
||||
console.log('Adding asset to workflow:', assetId)
|
||||
}
|
||||
|
||||
const openWorkflow = (assetId: string) => {
|
||||
console.log('Opening workflow for asset:', assetId)
|
||||
}
|
||||
|
||||
const exportWorkflow = (assetId: string) => {
|
||||
console.log('Exporting workflow for asset:', assetId)
|
||||
}
|
||||
|
||||
const openMoreOutputs = (assetId: string) => {
|
||||
console.log('Opening more outputs for asset:', assetId)
|
||||
}
|
||||
|
||||
return {
|
||||
selectAsset,
|
||||
viewAsset,
|
||||
downloadAsset,
|
||||
deleteAsset,
|
||||
playAsset,
|
||||
copyAssetUrl,
|
||||
copyJobId,
|
||||
addWorkflow,
|
||||
openWorkflow,
|
||||
exportWorkflow,
|
||||
openMoreOutputs
|
||||
}
|
||||
}
|
||||
46
src/platform/assets/schemas/mediaAssetSchema.ts
Normal file
46
src/platform/assets/schemas/mediaAssetSchema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { assetItemSchema } from './assetSchema'
|
||||
|
||||
const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D'])
|
||||
export type MediaKind = z.infer<typeof zMediaKindSchema>
|
||||
|
||||
const zDimensionsSchema = z.object({
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive()
|
||||
})
|
||||
|
||||
// Extend the base asset schema with media-specific fields
|
||||
const zMediaAssetDisplayItemSchema = assetItemSchema.extend({
|
||||
// New required fields
|
||||
kind: zMediaKindSchema,
|
||||
src: z.string().url(),
|
||||
|
||||
// New optional fields
|
||||
duration: z.number().nonnegative().optional(),
|
||||
dimensions: zDimensionsSchema.optional(),
|
||||
jobId: z.string().optional(),
|
||||
isMulti: z.boolean().optional()
|
||||
})
|
||||
|
||||
// Asset context schema
|
||||
const zAssetContextSchema = z.object({
|
||||
type: z.enum(['input', 'output']),
|
||||
outputCount: z.number().positive().optional() // Only for output context
|
||||
})
|
||||
|
||||
// Export the inferred types
|
||||
export type AssetMeta = z.infer<typeof zMediaAssetDisplayItemSchema>
|
||||
export type AssetContext = z.infer<typeof zAssetContextSchema>
|
||||
|
||||
// Injection key for MediaAsset provide/inject pattern
|
||||
interface MediaAssetProviderValue {
|
||||
asset: Ref<AssetMeta | undefined>
|
||||
context: Ref<AssetContext>
|
||||
isVideoPlaying: Ref<boolean>
|
||||
showVideoControls: Ref<boolean>
|
||||
}
|
||||
|
||||
export const MediaAssetKey: InjectionKey<MediaAssetProviderValue> =
|
||||
Symbol('mediaAsset')
|
||||
Reference in New Issue
Block a user