mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 18:22:40 +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'
|
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
||||||
|
|
||||||
const activeTab = ref<'input' | 'output'>('input')
|
const activeTab = ref<'input' | 'output'>('output')
|
||||||
const folderPromptId = ref<string | null>(null)
|
const folderPromptId = ref<string | null>(null)
|
||||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
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>
|
</template>
|
||||||
|
|
||||||
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
|
<!-- Top-left slot: Duration/Format chips OR Media actions -->
|
||||||
<template v-if="showActionsOverlay" #top-left>
|
<template #top-left>
|
||||||
<MediaAssetActions
|
<!-- Duration/Format chips - show when not hovered and not playing -->
|
||||||
:show-delete-button="showDeleteButton ?? true"
|
<div v-if="showStaticChips" class="flex flex-wrap items-center gap-1">
|
||||||
@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"
|
|
||||||
>
|
|
||||||
<SquareChip
|
<SquareChip
|
||||||
v-if="formattedDuration"
|
v-if="formattedDuration"
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -82,10 +55,38 @@
|
|||||||
/>
|
/>
|
||||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<!-- Output count (bottom-right) - show on hover even when playing -->
|
<!-- Output count (top-right) -->
|
||||||
<template v-if="showOutputCount" #bottom-right>
|
<template v-if="showOutputCount" #top-right>
|
||||||
<IconTextButton
|
<IconTextButton
|
||||||
type="secondary"
|
type="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -134,7 +135,9 @@ import { useElementHover } from '@vueuse/core'
|
|||||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||||
|
|
||||||
import IconButton from '@/components/button/IconButton.vue'
|
import IconButton from '@/components/button/IconButton.vue'
|
||||||
|
import IconGroup from '@/components/button/IconGroup.vue'
|
||||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
|
import MoreButton from '@/components/button/MoreButton.vue'
|
||||||
import CardBottom from '@/components/card/CardBottom.vue'
|
import CardBottom from '@/components/card/CardBottom.vue'
|
||||||
import CardContainer from '@/components/card/CardContainer.vue'
|
import CardContainer from '@/components/card/CardContainer.vue'
|
||||||
import CardTop from '@/components/card/CardTop.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 { AssetItem } from '../schemas/assetSchema'
|
||||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||||
import MediaAssetActions from './MediaAssetActions.vue'
|
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
||||||
|
|
||||||
const mediaComponents = {
|
const mediaComponents = {
|
||||||
top: {
|
top: {
|
||||||
@@ -285,37 +288,22 @@ const isCardOrOverlayHovered = computed(
|
|||||||
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
|
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const showHoverActions = computed(
|
// Show static chips when NOT hovered and NOT playing (normal state)
|
||||||
() => !loading && !!asset && isCardOrOverlayHovered.value
|
const showStaticChips = computed(
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
() =>
|
() =>
|
||||||
!loading &&
|
!loading &&
|
||||||
!!asset &&
|
!!asset &&
|
||||||
!!fileFormat.value &&
|
!isCardOrOverlayHovered.value &&
|
||||||
(!isVideoPlaying.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 = () => {
|
const handleOverlayMouseEnter = () => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<IconTextButton
|
<IconTextButton
|
||||||
v-if="asset?.kind !== '3D'"
|
v-if="asset?.kind !== '3D'"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
class="text-base-foreground"
|
|
||||||
label="Inspect asset"
|
label="Inspect asset"
|
||||||
@click="handleInspect"
|
@click="handleInspect"
|
||||||
>
|
>
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
<IconTextButton
|
<IconTextButton
|
||||||
v-if="showWorkflowOptions"
|
v-if="showWorkflowOptions"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
class="text-base-foreground"
|
|
||||||
label="Add to current workflow"
|
label="Add to current workflow"
|
||||||
@click="handleAddToWorkflow"
|
@click="handleAddToWorkflow"
|
||||||
>
|
>
|
||||||
@@ -24,12 +22,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</IconTextButton>
|
</IconTextButton>
|
||||||
|
|
||||||
<IconTextButton
|
<IconTextButton type="transparent" label="Download" @click="handleDownload">
|
||||||
type="transparent"
|
|
||||||
class="text-base-foreground"
|
|
||||||
label="Download"
|
|
||||||
@click="handleDownload"
|
|
||||||
>
|
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<i class="icon-[lucide--download] size-4" />
|
<i class="icon-[lucide--download] size-4" />
|
||||||
</template>
|
</template>
|
||||||
@@ -40,7 +33,6 @@
|
|||||||
<IconTextButton
|
<IconTextButton
|
||||||
v-if="showWorkflowOptions"
|
v-if="showWorkflowOptions"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
class="text-base-foreground"
|
|
||||||
label="Open as workflow in new tab"
|
label="Open as workflow in new tab"
|
||||||
@click="handleOpenWorkflow"
|
@click="handleOpenWorkflow"
|
||||||
>
|
>
|
||||||
@@ -52,7 +44,6 @@
|
|||||||
<IconTextButton
|
<IconTextButton
|
||||||
v-if="showWorkflowOptions"
|
v-if="showWorkflowOptions"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
class="text-base-foreground"
|
|
||||||
label="Export workflow"
|
label="Export workflow"
|
||||||
@click="handleExportWorkflow"
|
@click="handleExportWorkflow"
|
||||||
>
|
>
|
||||||
@@ -66,7 +57,6 @@
|
|||||||
<IconTextButton
|
<IconTextButton
|
||||||
v-if="showCopyJobId"
|
v-if="showCopyJobId"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
class="text-base-foreground"
|
|
||||||
label="Copy job ID"
|
label="Copy job ID"
|
||||||
@click="handleCopyJobId"
|
@click="handleCopyJobId"
|
||||||
>
|
>
|
||||||
@@ -80,7 +70,6 @@
|
|||||||
<IconTextButton
|
<IconTextButton
|
||||||
v-if="shouldShowDeleteButton"
|
v-if="shouldShowDeleteButton"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
class="text-base-foreground"
|
|
||||||
label="Delete"
|
label="Delete"
|
||||||
@click="handleDelete"
|
@click="handleDelete"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
|
|||||||
secondary:
|
secondary:
|
||||||
'bg-white border-none text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
|
'bg-white border-none text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
|
||||||
transparent:
|
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
|
} as const
|
||||||
|
|
||||||
return baseByType[type]
|
return baseByType[type]
|
||||||
|
|||||||
Reference in New Issue
Block a user