Add ... context menu to list view

This commit is contained in:
Benjamin Lu
2025-12-23 14:42:10 -08:00
parent 79af71530d
commit 5561efcfd3
3 changed files with 71 additions and 54 deletions

View File

@@ -9,7 +9,6 @@
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:icon-wrapper-class="getJobIconWrapperClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
@@ -69,8 +68,22 @@
:icon-name="getAssetIconName(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent="handleAssetMenuClick($event, item.asset)"
@click.stop="emit('select-asset', item.asset)"
/>
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.moreOptions')"
@click.stop="handleAssetMenuClick($event, item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListCard>
</template>
</VirtualGrid>
</div>
@@ -106,6 +119,7 @@ const { assets, isSelected } = defineProps<{
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()
@@ -113,6 +127,7 @@ const { t } = useI18n()
const { jobItems } = useJobList()
const { getJobActions, runJobAction } = useJobActions()
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem }
@@ -196,11 +211,14 @@ function onJobLeave(jobId: string) {
}
}
function getJobIconWrapperClass(job: JobListItem): string | undefined {
if (job.state === 'failed') {
return 'bg-modal-card-placeholder-background'
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}
function onAssetLeave(assetId: string) {
if (hoveredAssetId.value === assetId) {
hoveredAssetId.value = null
}
return undefined
}
function getJobIconClass(job: JobListItem): string | undefined {
@@ -209,13 +227,15 @@ function getJobIconClass(job: JobListItem): string | undefined {
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
if (job.state === 'failed') {
classes.push('text-destructive-background')
}
return classes.length ? classes.join(' ') : undefined
}
function handleJobAction(action: JobAction, job: JobListItem) {
void runJobAction(action, job)
}
function handleAssetMenuClick(event: MouseEvent, asset: AssetItem) {
event.stopPropagation()
emit('context-menu', event, asset)
}
</script>

View File

@@ -101,6 +101,7 @@
:assets="displayAssets"
:is-selected="isSelected"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<VirtualGrid
@@ -120,13 +121,10 @@
:selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-context-menu-id="openContextMenuId"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@context-menu-opened="openContextMenuId = item.id"
@context-menu="handleAssetContextMenu($event, item)"
/>
</template>
</VirtualGrid>
@@ -193,13 +191,23 @@
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
<MediaAssetContextMenu
v-if="contextMenuAsset"
ref="contextMenuRef"
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
@zoom="handleContextMenuZoom"
@asset-deleted="refreshAssets"
/>
</template>
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
@@ -212,13 +220,16 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -243,9 +254,8 @@ const isQueuePanelV2Enabled = computed(() =>
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
// Track which asset's context menu is open (for single-instance context menu management)
const openContextMenuId = ref<string | null>(null)
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
@@ -254,6 +264,14 @@ const shouldShowDeleteButton = computed(() => {
return true
})
const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1
@@ -439,6 +457,18 @@ const handleAssetSelect = (asset: AssetItem) => {
handleAssetClick(asset, index, displayAssets.value)
}
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
contextMenuAsset.value = asset
void nextTick(() => {
contextMenuRef.value?.show(event)
})
}
function handleContextMenuZoom() {
if (!contextMenuAsset.value) return
handleZoomClick(contextMenuAsset.value)
}
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)

View File

@@ -110,21 +110,10 @@
</CardBottom>
</template>
</CardContainer>
<MediaAssetContextMenu
v-if="asset"
ref="contextMenu"
:asset="asset"
:asset-type="assetType"
:file-kind="fileKind"
:show-delete-button="showDeleteButton"
@zoom="handleZoomClick"
@asset-deleted="emit('asset-deleted')"
/>
</template>
<script setup lang="ts">
import { useElementHover, whenever } from '@vueuse/core'
import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
@@ -141,7 +130,6 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
const mediaComponents = {
top: {
@@ -166,34 +154,22 @@ function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const {
asset,
loading,
selected,
showOutputCount,
outputCount,
showDeleteButton,
openContextMenuId
} = defineProps<{
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
asset?: AssetItem
loading?: boolean
selected?: boolean
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
openContextMenuId?: string | null
}>()
const emit = defineEmits<{
click: []
zoom: [asset: AssetItem]
'output-count-click': []
'asset-deleted': []
'context-menu-opened': []
'context-menu': [event: MouseEvent]
}>()
const cardContainerRef = ref<HTMLElement>()
const contextMenu = ref<InstanceType<typeof MediaAssetContextMenu>>()
const isVideoPlaying = ref(false)
const showVideoControls = ref(false)
@@ -302,15 +278,6 @@ const handleOutputCountClick = () => {
}
const handleContextMenu = (event: MouseEvent) => {
emit('context-menu-opened')
contextMenu.value?.show(event)
emit('context-menu', event)
}
// Close this context menu when another opens
whenever(
() => openContextMenuId && openContextMenuId !== asset?.id,
() => {
contextMenu.value?.hide()
}
)
</script>