feat: Add multi-select support for media assets (#6256)

## Summary
Implements file explorer-style multi-selection functionality for media
assets in the AssetsSidebarTab component.

## Changes

### Multi-Selection Interactions
- **Normal click**: Single selection (clears previous, selects new)
- **Shift + click**: Range selection (from last selected to current)
- **Ctrl/Cmd + click**: Toggle individual selection

### State Management
- Added `assetSelectionStore` to manage selected asset IDs using Set
- Created `useAssetSelection` composable for selection logic and
keyboard state

### UI Enhancements
- Display selection count in footer (output tab only)
- Interactive selection count that shows "Deselect all" on hover
- Added bulk action buttons for download/delete (UI only)

### Translation Keys
Added new keys under `mediaAsset.selection`:
- `selectedCount`: "{count} selected"
- `deselectAll`: "Deselect all"
- `downloadSelected`: "Download"
- `deleteSelected`: "Delete"

## Test Plan
- [x] Open Assets sidebar tab
- [x] Switch to Generated tab
- [x] Test single selection with normal click
- [x] Test range selection with Shift + click
- [x] Test toggle selection with Ctrl/Cmd + click
- [x] Verify selection count updates correctly
- [x] Test hover interaction on selection count
- [x] Click "Deselect all" to clear selection
- [x] Test bulk action buttons (UI only)

## Notes
- Bulk download/delete functionality to be implemented in separate PR
- Selection UI currently only shows in output (Generated) tab


[screen-capture.webm](https://github.com/user-attachments/assets/740315bd-9254-4af3-a0be-10846d810d65)
This commit is contained in:
Jin Yi
2025-10-29 16:26:44 +09:00
committed by GitHub
parent 22f307b468
commit 9651d2a5df
9 changed files with 510 additions and 72 deletions

View File

@@ -41,44 +41,102 @@
</TabList>
</template>
<template #body>
<VirtualGrid
v-if="displayAssets.length"
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem',
gap: '0.5rem'
}"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item"
:selected="selectedAsset?.id === item.id"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
<div v-if="displayAssets.length" class="relative size-full">
<VirtualGrid
v-if="displayAssets.length"
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem',
gap: '0.5rem'
}"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item"
:selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="!isInFolderView"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
/>
</template>
</VirtualGrid>
<div v-else-if="loading">
<ProgressSpinner
class="absolute left-1/2 w-[50px] -translate-x-1/2"
/>
</template>
</VirtualGrid>
<div v-else-if="loading">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
$t(
activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
$t(
activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</template>
<template #footer>
<div
v-if="hasSelection && activeTab === 'output'"
class="flex h-18 w-full items-center justify-between px-4"
>
<div>
<TextButton
v-if="isHoveringSelectionCount"
:label="$t('mediaAsset.selection.deselectAll')"
type="transparent"
@click="handleDeselectAll"
@mouseleave="isHoveringSelectionCount = false"
/>
<span
v-else
role="button"
tabindex="0"
:aria-label="$t('mediaAsset.selection.deselectAll')"
class="cursor-pointer px-3 text-sm focus:ring-2 focus:ring-primary focus:outline-none"
@mouseenter="isHoveringSelectionCount = true"
@keydown.enter="handleDeselectAll"
@keydown.space.prevent="handleDeselectAll"
>
{{
$t('mediaAsset.selection.selectedCount', { count: selectedCount })
}}
</span>
</div>
<div class="flex gap-2">
<IconTextButton
v-if="!isInFolderView"
:label="$t('mediaAsset.selection.deleteSelected')"
type="secondary"
icon-position="right"
@click="handleDeleteSelected"
>
<template #icon>
<i class="icon-[lucide--trash-2] size-4" />
</template>
</IconTextButton>
<IconTextButton
:label="$t('mediaAsset.selection.downloadSelected')"
type="secondary"
icon-position="right"
@click="handleDownloadSelected"
>
<template #icon>
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
</div>
</div>
</template>
</AssetsSidebarTemplate>
@@ -91,9 +149,10 @@
<script setup lang="ts">
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
@@ -102,6 +161,8 @@ import TabList from '@/components/tab/TabList.vue'
import { t } from '@/i18n'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { ResultItemImpl } from '@/stores/queueStore'
@@ -110,7 +171,6 @@ import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
const activeTab = ref<'input' | 'output'>('input')
const selectedAsset = ref<AssetItem | null>(null)
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
@@ -137,6 +197,23 @@ const toast = useToast()
const inputAssets = useMediaAssets('input')
const outputAssets = useMediaAssets('output')
// Asset selection
const {
isSelected,
handleAssetClick,
hasSelection,
selectedCount,
clearSelection,
getSelectedAssets,
activate: activateSelection,
deactivate: deactivateSelection
} = useAssetSelection()
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
// Hover state for selection count
const isHoveringSelectionCount = ref(false)
const currentAssets = computed(() =>
activeTab.value === 'input' ? inputAssets : outputAssets
)
@@ -213,17 +290,15 @@ const refreshAssets = async () => {
watch(
activeTab,
() => {
clearSelection()
void refreshAssets()
},
{ immediate: true }
)
const handleAssetSelect = (asset: AssetItem) => {
if (selectedAsset.value?.id === asset.id) {
selectedAsset.value = null
} else {
selectedAsset.value = asset
}
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, displayAssets.value)
}
const handleZoomClick = (asset: AssetItem) => {
@@ -272,6 +347,20 @@ const exitFolderView = () => {
folderPromptId.value = null
folderExecutionTime.value = undefined
folderAssets.value = []
clearSelection()
}
onMounted(() => {
activateSelection()
})
onUnmounted(() => {
deactivateSelection()
})
const handleDeselectAll = () => {
clearSelection()
isHoveringSelectionCount.value = false
}
const copyJobId = async () => {
@@ -294,4 +383,16 @@ const copyJobId = async () => {
}
}
}
const handleDownloadSelected = () => {
const selectedAssets = getSelectedAssets(displayAssets.value)
downloadMultipleAssets(selectedAssets)
clearSelection()
}
const handleDeleteSelected = async () => {
const selectedAssets = getSelectedAssets(displayAssets.value)
await deleteMultipleAssets(selectedAssets)
clearSelection()
}
</script>