feature: media asset card design changes (#7858)

This commit is contained in:
Jin Yi
2026-01-08 10:21:26 +09:00
committed by GitHub
parent c19a004f0d
commit dc64e16f7c
9 changed files with 136 additions and 273 deletions

View File

@@ -2,7 +2,8 @@
<div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
backgroundClass || 'bg-secondary-background'
)
"
>
@@ -12,4 +13,8 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const { backgroundClass } = defineProps<{
backgroundClass?: string
}>()
</script>

View File

@@ -18,7 +18,8 @@ export const buttonVariants = cva({
'muted-textonly':
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
'destructive-textonly':
'text-destructive-background bg-transparent hover:bg-destructive-background/10'
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -44,7 +45,8 @@ const variants = [
'destructive',
'textonly',
'muted-textonly',
'destructive-textonly'
'destructive-textonly',
'overlay-white'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']

View File

@@ -2373,6 +2373,8 @@
"actions": {
"inspect": "Inspect asset",
"more": "More options",
"zoom": "Zoom in",
"moreOptions": "More options",
"seeMoreOutputs": "See more outputs",
"addToWorkflow": "Add to current workflow",
"download": "Download",

View File

@@ -1,29 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<!-- TBD: File size will be provided by backend history API -->
<div
v-if="asset.size"
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'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<CardContainer
<div
ref="cardContainerRef"
role="button"
:aria-label="
@@ -11,112 +11,114 @@
: $t('assetBrowser.ariaLabel.loadingAsset')
"
:tabindex="loading ? -1 : 0"
size="mini"
variant="ghost"
rounded="lg"
:class="containerClasses"
:class="
cn(
'flex flex-col overflow-hidden cursor-pointer p-2 transition-colors duration-200 rounded-lg',
'gap-2 select-none group',
selected
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
: 'hover:bg-modal-card-background-hovered'
)
"
:data-selected="selected"
@click.stop="$emit('click')"
@contextmenu.prevent="handleContextMenu"
>
<template #top>
<CardTop
ratio="square"
:bottom-left-class="durationChipClasses"
:bottom-right-class="durationChipClasses"
<!-- Top Area: Media Preview -->
<div class="relative aspect-square overflow-hidden p-0">
<!-- Loading State -->
<div
v-if="loading"
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
/>
<!-- Content based on asset type -->
<component
:is="getTopComponent(fileKind)"
v-else-if="asset && adaptedAsset"
:asset="adaptedAsset"
:context="{ type: assetType }"
class="absolute inset-0"
@view="handleZoomClick"
@download="actions.downloadAsset()"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"
/>
<!-- Action buttons overlay (top-left) -->
<div
v-if="showActionsOverlay"
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
>
<!-- Loading State -->
<template v-if="loading">
<div
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
/>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset && adaptedAsset">
<component
:is="getTopComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
@view="handleZoomClick"
@download="actions.downloadAsset()"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"
/>
</template>
<!-- Top-left slot: Duration/Format chips OR Media actions -->
<template #top-left>
<!-- Duration/Format chips - show when not hovered and not playing -->
<div v-if="showStaticChips" class="flex flex-wrap items-center gap-1">
<SquareChip
v-if="formattedDuration"
variant="gray"
:label="formattedDuration"
/>
</div>
<!-- Media actions - show on hover or when playing -->
<IconGroup v-else-if="showActionsOverlay">
<Button size="icon" @click.stop="handleZoomClick">
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<Button size="icon" @click.stop="handleContextMenu">
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</IconGroup>
</template>
<!-- Output count or duration chip (top-right) -->
<template v-if="showOutputCount || showTouchDurationChip" #top-right>
<IconGroup background-class="bg-white">
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.zoom')"
@click.stop="handleZoomClick"
>
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop="handleContextMenu"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</IconGroup>
</div>
</div>
<!-- Bottom Area: Media Info -->
<div class="flex-1">
<!-- Loading State -->
<div v-if="loading" class="flex justify-between items-start">
<div class="flex flex-col gap-1">
<div
class="h-4 w-24 animate-pulse rounded bg-modal-card-background"
/>
<div
class="h-3 w-20 animate-pulse rounded bg-modal-card-background"
/>
</div>
<div class="h-6 w-12 animate-pulse rounded bg-modal-card-background" />
</div>
<!-- Content -->
<div
v-else-if="asset && adaptedAsset"
class="flex justify-between items-end gap-1.5"
>
<!-- Left side: Media name and metadata -->
<div class="flex flex-col gap-1">
<!-- Title -->
<MediaTitle :file-name="fileName" />
<!-- Metadata -->
<div class="flex gap-1.5 text-xs text-muted-foreground">
<span v-if="formattedDuration">{{ formattedDuration }}</span>
<span v-if="metaInfo">{{ metaInfo }}</span>
</div>
</div>
<!-- Right side: Output count -->
<div v-if="showOutputCount" class="flex-shrink-0">
<Button
v-if="showOutputCount"
v-tooltip.top.pt:pointer-events-none="
$t('mediaAsset.actions.seeMoreOutputs')
"
variant="secondary"
size="sm"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />
<span>{{ outputCount }}</span>
</Button>
<!-- Duration chip on touch devices (far right) -->
<SquareChip
v-else-if="showTouchDurationChip"
variant="gray"
:label="formattedDuration"
/>
</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-modal-card-background"
/>
<div
class="h-3 w-1/2 animate-pulse rounded bg-modal-card-background"
/>
</div>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset && adaptedAsset">
<component
:is="getBottomComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
/>
</template>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
</div>
</div>
<MediaAssetContextMenu
v-if="asset"
@@ -131,16 +133,17 @@
</template>
<script setup lang="ts">
import { useElementHover, useMediaQuery, whenever } from '@vueuse/core'
import { useElementHover, whenever } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.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 Button from '@/components/ui/button/Button.vue'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import {
formatDuration,
formatSize,
getFilenameDetails,
getMediaTypeFromFilename
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
@@ -149,6 +152,7 @@ import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
import MediaTitle from './MediaTitle.vue'
const mediaComponents = {
top: {
@@ -156,12 +160,6 @@ const mediaComponents = {
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'))
}
}
@@ -169,10 +167,6 @@ function getTopComponent(kind: MediaKind) {
return mediaComponents.top[kind] || mediaComponents.top.image
}
function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const {
asset,
loading,
@@ -209,7 +203,6 @@ const showVideoControls = ref(false)
const imageDimensions = ref<{ width: number; height: number } | undefined>()
const isHovered = useElementHover(cardContainerRef)
const isTouch = useMediaQuery('(hover: none)')
const actions = useMediaAssetActions()
@@ -223,6 +216,11 @@ const fileKind = computed((): MediaKind => {
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
})
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
const adaptedAsset = computed(() => {
if (!asset) return undefined
@@ -248,15 +246,6 @@ provide(MediaAssetKey, {
showVideoControls
})
const containerClasses = computed(() =>
cn(
'gap-1 select-none group',
selected
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
: 'hover:bg-modal-card-background-hovered'
)
)
const formattedDuration = computed(() => {
// Check for execution time first (from history API)
const executionTime = asset?.user_metadata?.executionTimeInSeconds
@@ -270,39 +259,22 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const durationChipClasses = computed(() => {
if (fileKind.value === 'audio') {
return '-translate-y-11'
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
if (fileKind.value === 'image' && imageDimensions.value) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (fileKind.value === 'video' && showVideoControls.value) {
return '-translate-y-16'
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)
}
return ''
})
// Show static chips when NOT hovered and NOT playing (normal state on non-touch)
const showStaticChips = computed(
() =>
!loading &&
!!asset &&
!isHovered.value &&
!isVideoPlaying.value &&
!isTouch.value &&
formattedDuration.value
)
// Show duration chip in top-right on touch devices
const showTouchDurationChip = computed(
() => !loading && !!asset && isTouch.value && formattedDuration.value
)
// Show action overlay when hovered, playing, or on touch device
const showActionsOverlay = computed(
() =>
!loading &&
!!asset &&
(isHovered.value || isVideoPlaying.value || isTouch.value)
)
const showActionsOverlay = computed(() => {
if (loading || !asset) return false
return isHovered.value || selected || isVideoPlaying.value
})
const handleZoomClick = () => {
if (asset) {

View File

@@ -1,29 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<!-- TBD: File size will be provided by backend history API -->
<div
v-if="asset.size"
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'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -1,27 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<div class="flex items-center text-xs text-zinc-400">
<span v-if="asset.dimensions"
>{{ 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 { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -1,21 +1,14 @@
<template>
<h3
class="m-0 line-clamp-1 text-sm font-bold text-base-foreground"
:title="fullName"
<p
class="m-0 line-clamp-2 text-sm text-base-foreground leading-tight break-all"
:title="fileName"
>
{{ displayName }}
</h3>
{{ fileName }}
</p>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { truncateFilename } from '@/utils/formatUtil'
const props = defineProps<{
defineProps<{
fileName: string
}>()
const fullName = computed(() => props.fileName)
const displayName = computed(() => truncateFilename(props.fileName))
</script>

View File

@@ -1,26 +0,0 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<!-- TBD: File size will be provided by backend history API -->
<div v-if="asset.size" class="flex items-center 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'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>