mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
feature: media asset card design changes (#7858)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user