[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:
Jin Yi
2025-11-03 07:06:55 +09:00
committed by GitHub
parent 0692253e90
commit 8dfdac3fc4
5 changed files with 53 additions and 153 deletions

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -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"
>

View File

@@ -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]