[QPOv2] Add ... context menu to list view (#7745)

Add ... context menu to list view

This is the same ... context menu used in the grid view, now moved up to
the tab scope so it can be shared between views.

Part of the QPO v2 iteration, figma design can be found
[here](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3330-37286&m=dev).
This will be implemented in a series of stacked PRs that can be reviewed
and merged individually.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7745-QPOv2-Add-context-menu-to-list-view-2d26d73d365081329a11ce97472bbf87)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Benjamin Lu
2026-01-13 16:21:06 -08:00
committed by GitHub
parent eb213d0ad3
commit 84662cb94c
4 changed files with 89 additions and 70 deletions

View File

@@ -74,8 +74,22 @@
" "
:primary-text="getAssetPrimaryText(item.asset)" :primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)" :secondary-text="getAssetSecondaryText(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', 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('mediaAsset.actions.moreOptions')"
@click.stop="emit('context-menu', $event, item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</template> </template>
</VirtualGrid> </VirtualGrid>
</div> </div>
@@ -111,12 +125,14 @@ const { assets, isSelected } = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void (e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void (e: 'approach-end'): void
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const { jobItems } = useJobList() const { jobItems } = useJobList()
const hoveredJobId = ref<string | null>(null) const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem } type AssetListItem = { key: string; asset: AssetItem }
@@ -192,6 +208,16 @@ function onJobLeave(jobId: string) {
} }
} }
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}
function onAssetLeave(assetId: string) {
if (hoveredAssetId.value === assetId) {
hoveredAssetId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined { function getJobIconClass(job: JobListItem): string | undefined {
const classes = [] const classes = []
const iconName = job.iconName ?? iconForJobState(job.state) const iconName = job.iconName ?? iconForJobState(job.state)

View File

@@ -101,6 +101,7 @@
:assets="displayAssets" :assets="displayAssets"
:is-selected="isSelected" :is-selected="isSelected"
@select-asset="handleAssetSelect" @select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd" @approach-end="handleApproachEnd"
/> />
<VirtualGrid <VirtualGrid
@@ -120,17 +121,10 @@
:selected="isSelected(item.id)" :selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)" :show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)" :output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-context-menu-id="openContextMenuId"
:selected-assets="getSelectedAssets(displayAssets)"
:has-selection="hasSelection"
@click="handleAssetSelect(item)" @click="handleAssetSelect(item)"
@context-menu="handleAssetContextMenu"
@zoom="handleZoomClick(item)" @zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)" @output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@context-menu-opened="openContextMenuId = item.id"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
/> />
</template> </template>
</VirtualGrid> </VirtualGrid>
@@ -196,6 +190,21 @@
v-model:active-index="galleryActiveIndex" v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems" :all-gallery-items="galleryItems"
/> />
<MediaAssetContextMenu
v-if="contextMenuAsset"
ref="contextMenuRef"
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@zoom="handleZoomClick(contextMenuAsset)"
@hide="handleContextMenuHide"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -203,7 +212,7 @@ import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import Divider from 'primevue/divider' import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner' import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast' 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 { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
@@ -216,13 +225,16 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue' import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.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 MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets' import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection' import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions' import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering' import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
@@ -248,8 +260,8 @@ const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list' () => isQueuePanelV2Enabled.value && viewMode.value === 'list'
) )
// Track which asset's context menu is open (for single-instance context menu management) const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const openContextMenuId = ref<string | null>(null) const contextMenuAsset = ref<AssetItem | null>(null)
// Determine if delete button should be shown // 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) // Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
@@ -258,6 +270,14 @@ const shouldShowDeleteButton = computed(() => {
return true return true
}) })
const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const shouldShowOutputCount = (item: AssetItem): boolean => { const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) { if (activeTab.value !== 'output' || isInFolderView.value) {
return false return false
@@ -327,8 +347,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets // Total output count for all selected assets
const totalOutputCount = computed(() => { const totalOutputCount = computed(() => {
const selectedAssets = getSelectedAssets(displayAssets.value) return getTotalOutputCount(selectedAssets.value)
return getTotalOutputCount(selectedAssets)
}) })
const currentAssets = computed(() => const currentAssets = computed(() =>
@@ -359,6 +378,12 @@ const displayAssets = computed(() => {
return filteredAssets.value return filteredAssets.value
}) })
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
)
const showLoadingState = computed( const showLoadingState = computed(
() => () =>
loading.value && loading.value &&
@@ -444,6 +469,17 @@ const handleAssetSelect = (asset: AssetItem) => {
handleAssetClick(asset, index, displayAssets.value) handleAssetClick(asset, index, displayAssets.value)
} }
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
contextMenuAsset.value = asset
void nextTick(() => {
contextMenuRef.value?.show(event)
})
}
function handleContextMenuHide() {
contextMenuAsset.value = null
}
const handleZoomClick = (asset: AssetItem) => { const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name) const mediaType = getMediaTypeFromFilename(asset.name)
@@ -552,14 +588,12 @@ const copyJobId = async () => {
} }
const handleDownloadSelected = () => { const handleDownloadSelected = () => {
const selectedAssets = getSelectedAssets(displayAssets.value) downloadMultipleAssets(selectedAssets.value)
downloadMultipleAssets(selectedAssets)
clearSelection() clearSelection()
} }
const handleDeleteSelected = async () => { const handleDeleteSelected = async () => {
const selectedAssets = getSelectedAssets(displayAssets.value) await deleteMultipleAssets(selectedAssets.value)
await deleteMultipleAssets(selectedAssets)
clearSelection() clearSelection()
} }

View File

@@ -22,7 +22,9 @@
" "
:data-selected="selected" :data-selected="selected"
@click.stop="$emit('click')" @click.stop="$emit('click')"
@contextmenu.prevent="handleContextMenu" @contextmenu.prevent.stop="
asset ? emit('context-menu', $event, asset) : undefined
"
> >
<!-- Top Area: Media Preview --> <!-- Top Area: Media Preview -->
<div class="relative aspect-square overflow-hidden p-0"> <div class="relative aspect-square overflow-hidden p-0">
@@ -64,7 +66,9 @@
variant="overlay-white" variant="overlay-white"
size="icon" size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')" :aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop="handleContextMenu" @click.stop="
asset ? emit('context-menu', $event, asset) : undefined
"
> >
<i class="icon-[lucide--ellipsis] size-4" /> <i class="icon-[lucide--ellipsis] size-4" />
</Button> </Button>
@@ -119,25 +123,10 @@
</div> </div>
</div> </div>
</div> </div>
<MediaAssetContextMenu
v-if="asset"
ref="contextMenu"
:asset="asset"
:asset-type="assetType"
:file-kind="fileKind"
:show-delete-button="showDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="hasSelection && (selectedAssets?.length ?? 0) > 1"
@zoom="handleZoomClick"
@asset-deleted="emit('asset-deleted')"
@bulk-download="emit('bulk-download', $event)"
@bulk-delete="emit('bulk-delete', $event)"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useElementHover, whenever } from '@vueuse/core' import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue' import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue' import IconGroup from '@/components/button/IconGroup.vue'
@@ -155,7 +144,6 @@ 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 MediaAssetContextMenu from './MediaAssetContextMenu.vue'
import MediaTitle from './MediaTitle.vue' import MediaTitle from './MediaTitle.vue'
const mediaComponents = { const mediaComponents = {
@@ -171,40 +159,22 @@ function getTopComponent(kind: MediaKind) {
return mediaComponents.top[kind] || mediaComponents.top.image return mediaComponents.top[kind] || mediaComponents.top.image
} }
const { const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
asset,
loading,
selected,
showOutputCount,
outputCount,
showDeleteButton,
openContextMenuId,
selectedAssets,
hasSelection
} = defineProps<{
asset?: AssetItem asset?: AssetItem
loading?: boolean loading?: boolean
selected?: boolean selected?: boolean
showOutputCount?: boolean showOutputCount?: boolean
outputCount?: number outputCount?: number
showDeleteButton?: boolean
openContextMenuId?: string | null
selectedAssets?: AssetItem[]
hasSelection?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
click: [] click: []
zoom: [asset: AssetItem] zoom: [asset: AssetItem]
'output-count-click': [] 'output-count-click': []
'asset-deleted': [] 'context-menu': [event: MouseEvent, asset: AssetItem]
'context-menu-opened': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
}>() }>()
const cardContainerRef = ref<HTMLElement>() const cardContainerRef = ref<HTMLElement>()
const contextMenu = ref<InstanceType<typeof MediaAssetContextMenu>>()
const isVideoPlaying = ref(false) const isVideoPlaying = ref(false)
const showVideoControls = ref(false) const showVideoControls = ref(false)
@@ -299,17 +269,4 @@ const handleImageLoaded = (width: number, height: number) => {
const handleOutputCountClick = () => { const handleOutputCountClick = () => {
emit('output-count-click') emit('output-count-click')
} }
const handleContextMenu = (event: MouseEvent) => {
emit('context-menu-opened')
contextMenu.value?.show(event)
}
// Close this context menu when another opens
whenever(
() => openContextMenuId && openContextMenuId !== asset?.id,
() => {
contextMenu.value?.hide()
}
)
</script> </script>

View File

@@ -11,6 +11,7 @@
) )
} }
}" }"
@hide="emit('hide')"
> >
<template #item="{ item, props }"> <template #item="{ item, props }">
<Button <Button
@@ -65,6 +66,7 @@ const emit = defineEmits<{
'asset-deleted': [] 'asset-deleted': []
'bulk-download': [assets: AssetItem[]] 'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]] 'bulk-delete': [assets: AssetItem[]]
hide: []
}>() }>()
const contextMenu = ref<InstanceType<typeof ContextMenu>>() const contextMenu = ref<InstanceType<typeof ContextMenu>>()