mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[UI] Redesign media asset card layout (#6541)
## Summary - Reorganized media asset card layout for improved UX - Moved duration/format chips to top-left (visible in default state) - Replaced multiple action buttons with zoom + more menu pattern - Repositioned output count to top-right for better visibility ## Changes 1. **Duration & format chips**: Moved from bottom-left to top-left, shown when card is not hovered and media is not playing 2. **Media actions**: Simplified to zoom button + more menu (contains delete, download options), shown on hover or during playback 3. **Output count**: Relocated from bottom-right to top-right for consistent positioning ## Test plan - [x] Verify duration/format chips appear in top-left when card is idle - [x] Confirm action buttons (zoom + more) appear on hover - [x] Check output count displays correctly in top-right - [x] Test transitions between hover/non-hover states - [x] Verify media playback doesn't interfere with UI elements 🤖 Generated with [Claude Code](https://claude.ai/code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6541-UI-Redesign-media-asset-card-layout-29f6d73d365081eb8614d5dc9b2dc214) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -170,7 +170,7 @@ import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('input')
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<IconGroup>
|
||||
<IconButton v-if="shouldShowDeleteButton" 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"
|
||||
:show-delete-button="showDeleteButton"
|
||||
@inspect="emit('inspect')"
|
||||
@asset-deleted="emit('asset-deleted')"
|
||||
/>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, 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 { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
||||
|
||||
const { showDeleteButton } = defineProps<{
|
||||
showDeleteButton?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
menuStateChanged: [isOpen: boolean]
|
||||
inspect: []
|
||||
'asset-deleted': []
|
||||
}>()
|
||||
|
||||
const { asset, context } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assetType = computed(() => {
|
||||
return context?.value?.type || asset.value?.tags?.[0] || 'output'
|
||||
})
|
||||
|
||||
const shouldShowDeleteButton = computed(() => {
|
||||
const propAllows = showDeleteButton ?? true
|
||||
const typeAllows =
|
||||
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
|
||||
|
||||
return propAllows && typeAllows
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!asset.value) return
|
||||
|
||||
const success = await actions.confirmDelete(asset.value)
|
||||
if (success) {
|
||||
emit('asset-deleted')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -44,37 +44,10 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
|
||||
<template v-if="showActionsOverlay" #top-left>
|
||||
<MediaAssetActions
|
||||
:show-delete-button="showDeleteButton ?? true"
|
||||
@menu-state-changed="isMenuOpen = $event"
|
||||
@inspect="handleZoomClick"
|
||||
@asset-deleted="handleAssetDelete"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Zoom button (top-right) - show on hover for all media types -->
|
||||
<template v-if="showZoomOverlay" #top-right>
|
||||
<IconButton
|
||||
size="sm"
|
||||
@click.stop="handleZoomClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<!-- Duration/Format chips (bottom-left) - show on hover even when playing -->
|
||||
<template v-if="showDurationChips || showFileFormatChip" #bottom-left>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<!-- 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="light"
|
||||
@@ -82,10 +55,38 @@
|
||||
/>
|
||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||
</div>
|
||||
|
||||
<!-- Media actions - show on hover or when playing -->
|
||||
<IconGroup v-else-if="showActionsOverlay">
|
||||
<IconButton
|
||||
size="sm"
|
||||
@click.stop="handleZoomClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
<MoreButton
|
||||
size="sm"
|
||||
@menu-opened="isMenuOpen = true"
|
||||
@menu-closed="isMenuOpen = false"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetMoreMenu
|
||||
:close="close"
|
||||
:show-delete-button="showDeleteButton"
|
||||
@inspect="handleZoomClick"
|
||||
@asset-deleted="handleAssetDelete"
|
||||
/>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
<!-- Output count (bottom-right) - show on hover even when playing -->
|
||||
<template v-if="showOutputCount" #bottom-right>
|
||||
<!-- Output count (top-right) -->
|
||||
<template v-if="showOutputCount" #top-right>
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
size="sm"
|
||||
@@ -134,7 +135,9 @@ import { useElementHover } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
@@ -147,7 +150,7 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetActions from './MediaAssetActions.vue'
|
||||
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
@@ -285,37 +288,22 @@ const isCardOrOverlayHovered = computed(
|
||||
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
|
||||
)
|
||||
|
||||
const showHoverActions = computed(
|
||||
() => !loading && !!asset && isCardOrOverlayHovered.value
|
||||
)
|
||||
|
||||
const showActionsOverlay = computed(
|
||||
() =>
|
||||
showHoverActions.value &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showZoomOverlay = computed(
|
||||
() =>
|
||||
showHoverActions.value &&
|
||||
fileKind.value !== '3D' &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showDurationChips = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
(asset?.user_metadata?.executionTimeInSeconds ||
|
||||
asset?.user_metadata?.duration) &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showFileFormatChip = computed(
|
||||
// Show static chips when NOT hovered and NOT playing (normal state)
|
||||
const showStaticChips = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
!!asset &&
|
||||
!!fileFormat.value &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
!isCardOrOverlayHovered.value &&
|
||||
!isVideoPlaying.value &&
|
||||
(formattedDuration.value || fileFormat.value)
|
||||
)
|
||||
|
||||
// Show action overlay when hovered OR playing
|
||||
const showActionsOverlay = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
!!asset &&
|
||||
(isCardOrOverlayHovered.value || isVideoPlaying.value)
|
||||
)
|
||||
|
||||
const handleOverlayMouseEnter = () => {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<IconTextButton
|
||||
v-if="asset?.kind !== '3D'"
|
||||
type="transparent"
|
||||
class="text-base-foreground"
|
||||
label="Inspect asset"
|
||||
@click="handleInspect"
|
||||
>
|
||||
@@ -15,7 +14,6 @@
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="text-base-foreground"
|
||||
label="Add to current workflow"
|
||||
@click="handleAddToWorkflow"
|
||||
>
|
||||
@@ -24,12 +22,7 @@
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="text-base-foreground"
|
||||
label="Download"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<IconTextButton type="transparent" label="Download" @click="handleDownload">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
@@ -40,7 +33,6 @@
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="text-base-foreground"
|
||||
label="Open as workflow in new tab"
|
||||
@click="handleOpenWorkflow"
|
||||
>
|
||||
@@ -52,7 +44,6 @@
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="text-base-foreground"
|
||||
label="Export workflow"
|
||||
@click="handleExportWorkflow"
|
||||
>
|
||||
@@ -66,7 +57,6 @@
|
||||
<IconTextButton
|
||||
v-if="showCopyJobId"
|
||||
type="transparent"
|
||||
class="text-base-foreground"
|
||||
label="Copy job ID"
|
||||
@click="handleCopyJobId"
|
||||
>
|
||||
@@ -80,7 +70,6 @@
|
||||
<IconTextButton
|
||||
v-if="shouldShowDeleteButton"
|
||||
type="transparent"
|
||||
class="text-base-foreground"
|
||||
label="Delete"
|
||||
@click="handleDelete"
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
|
||||
secondary:
|
||||
'bg-white border-none text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
|
||||
transparent:
|
||||
'bg-transparent border-none text-neutral-600 dark-theme:text-neutral-400'
|
||||
'bg-transparent border-none text-neutral-600 dark-theme:text-neutral-200'
|
||||
} as const
|
||||
|
||||
return baseByType[type]
|
||||
|
||||
Reference in New Issue
Block a user