Skip to content

Commit 9651d2a

Browse files
authored
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)
1 parent 22f307b commit 9651d2a

File tree

9 files changed

+510
-72
lines changed

9 files changed

+510
-72
lines changed

src/components/sidebar/tabs/AssetSidebarTemplate.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
<ScrollPanel class="h-0 grow">
1919
<slot name="body" />
2020
</ScrollPanel>
21+
<div v-if="slots.footer">
22+
<slot name="footer" />
23+
</div>
2124
</div>
2225
</template>
2326

src/components/sidebar/tabs/AssetsSidebarTab.vue

Lines changed: 144 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -41,44 +41,102 @@
4141
</TabList>
4242
</template>
4343
<template #body>
44-
<VirtualGrid
45-
v-if="displayAssets.length"
46-
:items="mediaAssetsWithKey"
47-
:grid-style="{
48-
display: 'grid',
49-
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
50-
padding: '0.5rem',
51-
gap: '0.5rem'
52-
}"
53-
>
54-
<template #item="{ item }">
55-
<MediaAssetCard
56-
:asset="item"
57-
:selected="selectedAsset?.id === item.id"
58-
:show-output-count="shouldShowOutputCount(item)"
59-
:output-count="getOutputCount(item)"
60-
@click="handleAssetSelect(item)"
61-
@zoom="handleZoomClick(item)"
62-
@output-count-click="enterFolderView(item)"
63-
@asset-deleted="refreshAssets"
44+
<div v-if="displayAssets.length" class="relative size-full">
45+
<VirtualGrid
46+
v-if="displayAssets.length"
47+
:items="mediaAssetsWithKey"
48+
:grid-style="{
49+
display: 'grid',
50+
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
51+
padding: '0.5rem',
52+
gap: '0.5rem'
53+
}"
54+
>
55+
<template #item="{ item }">
56+
<MediaAssetCard
57+
:asset="item"
58+
:selected="isSelected(item.id)"
59+
:show-output-count="shouldShowOutputCount(item)"
60+
:output-count="getOutputCount(item)"
61+
:show-delete-button="!isInFolderView"
62+
@click="handleAssetSelect(item)"
63+
@zoom="handleZoomClick(item)"
64+
@output-count-click="enterFolderView(item)"
65+
@asset-deleted="refreshAssets"
66+
/>
67+
</template>
68+
</VirtualGrid>
69+
<div v-else-if="loading">
70+
<ProgressSpinner
71+
class="absolute left-1/2 w-[50px] -translate-x-1/2"
72+
/>
73+
</div>
74+
<div v-else>
75+
<NoResultsPlaceholder
76+
icon="pi pi-info-circle"
77+
:title="
78+
$t(
79+
activeTab === 'input'
80+
? 'sideToolbar.noImportedFiles'
81+
: 'sideToolbar.noGeneratedFiles'
82+
)
83+
"
84+
:message="$t('sideToolbar.noFilesFoundMessage')"
6485
/>
65-
</template>
66-
</VirtualGrid>
67-
<div v-else-if="loading">
68-
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
86+
</div>
6987
</div>
70-
<div v-else>
71-
<NoResultsPlaceholder
72-
icon="pi pi-info-circle"
73-
:title="
74-
$t(
75-
activeTab === 'input'
76-
? 'sideToolbar.noImportedFiles'
77-
: 'sideToolbar.noGeneratedFiles'
78-
)
79-
"
80-
:message="$t('sideToolbar.noFilesFoundMessage')"
81-
/>
88+
</template>
89+
<template #footer>
90+
<div
91+
v-if="hasSelection && activeTab === 'output'"
92+
class="flex h-18 w-full items-center justify-between px-4"
93+
>
94+
<div>
95+
<TextButton
96+
v-if="isHoveringSelectionCount"
97+
:label="$t('mediaAsset.selection.deselectAll')"
98+
type="transparent"
99+
@click="handleDeselectAll"
100+
@mouseleave="isHoveringSelectionCount = false"
101+
/>
102+
<span
103+
v-else
104+
role="button"
105+
tabindex="0"
106+
:aria-label="$t('mediaAsset.selection.deselectAll')"
107+
class="cursor-pointer px-3 text-sm focus:ring-2 focus:ring-primary focus:outline-none"
108+
@mouseenter="isHoveringSelectionCount = true"
109+
@keydown.enter="handleDeselectAll"
110+
@keydown.space.prevent="handleDeselectAll"
111+
>
112+
{{
113+
$t('mediaAsset.selection.selectedCount', { count: selectedCount })
114+
}}
115+
</span>
116+
</div>
117+
<div class="flex gap-2">
118+
<IconTextButton
119+
v-if="!isInFolderView"
120+
:label="$t('mediaAsset.selection.deleteSelected')"
121+
type="secondary"
122+
icon-position="right"
123+
@click="handleDeleteSelected"
124+
>
125+
<template #icon>
126+
<i class="icon-[lucide--trash-2] size-4" />
127+
</template>
128+
</IconTextButton>
129+
<IconTextButton
130+
:label="$t('mediaAsset.selection.downloadSelected')"
131+
type="secondary"
132+
icon-position="right"
133+
@click="handleDownloadSelected"
134+
>
135+
<template #icon>
136+
<i class="icon-[lucide--download] size-4" />
137+
</template>
138+
</IconTextButton>
139+
</div>
82140
</div>
83141
</template>
84142
</AssetsSidebarTemplate>
@@ -91,9 +149,10 @@
91149
<script setup lang="ts">
92150
import ProgressSpinner from 'primevue/progressspinner'
93151
import { useToast } from 'primevue/usetoast'
94-
import { computed, ref, watch } from 'vue'
152+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
95153
96154
import IconTextButton from '@/components/button/IconTextButton.vue'
155+
import TextButton from '@/components/button/TextButton.vue'
97156
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
98157
import VirtualGrid from '@/components/common/VirtualGrid.vue'
99158
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
@@ -102,6 +161,8 @@ import TabList from '@/components/tab/TabList.vue'
102161
import { t } from '@/i18n'
103162
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
104163
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
164+
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
165+
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
105166
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
106167
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
107168
import { ResultItemImpl } from '@/stores/queueStore'
@@ -110,7 +171,6 @@ import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
110171
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
111172
112173
const activeTab = ref<'input' | 'output'>('input')
113-
const selectedAsset = ref<AssetItem | null>(null)
114174
const folderPromptId = ref<string | null>(null)
115175
const folderExecutionTime = ref<number | undefined>(undefined)
116176
const isInFolderView = computed(() => folderPromptId.value !== null)
@@ -137,6 +197,23 @@ const toast = useToast()
137197
const inputAssets = useMediaAssets('input')
138198
const outputAssets = useMediaAssets('output')
139199
200+
// Asset selection
201+
const {
202+
isSelected,
203+
handleAssetClick,
204+
hasSelection,
205+
selectedCount,
206+
clearSelection,
207+
getSelectedAssets,
208+
activate: activateSelection,
209+
deactivate: deactivateSelection
210+
} = useAssetSelection()
211+
212+
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
213+
214+
// Hover state for selection count
215+
const isHoveringSelectionCount = ref(false)
216+
140217
const currentAssets = computed(() =>
141218
activeTab.value === 'input' ? inputAssets : outputAssets
142219
)
@@ -213,17 +290,15 @@ const refreshAssets = async () => {
213290
watch(
214291
activeTab,
215292
() => {
293+
clearSelection()
216294
void refreshAssets()
217295
},
218296
{ immediate: true }
219297
)
220298
221299
const handleAssetSelect = (asset: AssetItem) => {
222-
if (selectedAsset.value?.id === asset.id) {
223-
selectedAsset.value = null
224-
} else {
225-
selectedAsset.value = asset
226-
}
300+
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
301+
handleAssetClick(asset, index, displayAssets.value)
227302
}
228303
229304
const handleZoomClick = (asset: AssetItem) => {
@@ -272,6 +347,20 @@ const exitFolderView = () => {
272347
folderPromptId.value = null
273348
folderExecutionTime.value = undefined
274349
folderAssets.value = []
350+
clearSelection()
351+
}
352+
353+
onMounted(() => {
354+
activateSelection()
355+
})
356+
357+
onUnmounted(() => {
358+
deactivateSelection()
359+
})
360+
361+
const handleDeselectAll = () => {
362+
clearSelection()
363+
isHoveringSelectionCount.value = false
275364
}
276365
277366
const copyJobId = async () => {
@@ -294,4 +383,16 @@ const copyJobId = async () => {
294383
}
295384
}
296385
}
386+
387+
const handleDownloadSelected = () => {
388+
const selectedAssets = getSelectedAssets(displayAssets.value)
389+
downloadMultipleAssets(selectedAssets)
390+
clearSelection()
391+
}
392+
393+
const handleDeleteSelected = async () => {
394+
const selectedAssets = getSelectedAssets(displayAssets.value)
395+
await deleteMultipleAssets(selectedAssets)
396+
clearSelection()
397+
}
297398
</script>

src/locales/en/main.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,8 @@
17791779
"mediaAsset": {
17801780
"deleteAssetTitle": "Delete this asset?",
17811781
"deleteAssetDescription": "This asset will be permanently removed.",
1782+
"deleteSelectedTitle": "Delete selected assets?",
1783+
"deleteSelectedDescription": "{count} asset(s) will be permanently removed.",
17821784
"assetDeletedSuccessfully": "Asset deleted successfully",
17831785
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
17841786
"failedToDeleteAsset": "Failed to delete asset",
@@ -1787,6 +1789,16 @@
17871789
"jobIdCopyFailed": "Failed to copy Job ID",
17881790
"copied": "Copied",
17891791
"error": "Error"
1792+
},
1793+
"selection": {
1794+
"selectedCount": "Assets Selected: {count}",
1795+
"deselectAll": "Deselect all",
1796+
"downloadSelected": "Download",
1797+
"deleteSelected": "Delete",
1798+
"downloadStarted": "Downloading {count} files...",
1799+
"downloadsStarted": "Started downloading {count} file(s)",
1800+
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
1801+
"failedToDeleteAssets": "Failed to delete selected assets"
17901802
}
17911803
},
17921804
"actionbar": {

src/platform/assets/components/MediaAssetActions.vue

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<IconGroup>
3-
<IconButton v-if="showDeleteButton" size="sm" @click="handleDelete">
3+
<IconButton v-if="shouldShowDeleteButton" size="sm" @click="handleDelete">
44
<i class="icon-[lucide--trash-2] size-4" />
55
</IconButton>
66
<IconButton size="sm" @click="handleDownload">
@@ -14,6 +14,7 @@
1414
<template #default="{ close }">
1515
<MediaAssetMoreMenu
1616
:close="close"
17+
:show-delete-button="showDeleteButton"
1718
@inspect="emit('inspect')"
1819
@asset-deleted="emit('asset-deleted')"
1920
/>
@@ -34,6 +35,10 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
3435
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
3536
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
3637
38+
const { showDeleteButton } = defineProps<{
39+
showDeleteButton?: boolean
40+
}>()
41+
3742
const emit = defineEmits<{
3843
menuStateChanged: [isOpen: boolean]
3944
inspect: []
@@ -47,10 +52,12 @@ const assetType = computed(() => {
4752
return context?.value?.type || asset.value?.tags?.[0] || 'output'
4853
})
4954
50-
const showDeleteButton = computed(() => {
51-
return (
55+
const shouldShowDeleteButton = computed(() => {
56+
const propAllows = showDeleteButton ?? true
57+
const typeAllows =
5258
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
53-
)
59+
60+
return propAllows && typeAllows
5461
})
5562
5663
const handleDelete = async () => {

src/platform/assets/components/MediaAssetCard.vue

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@
1515
variant="ghost"
1616
rounded="lg"
1717
:class="containerClasses"
18-
@click="handleCardClick"
19-
@keydown.enter="handleCardClick"
20-
@keydown.space.prevent="handleCardClick"
2118
>
2219
<template #top>
2320
<CardTop
@@ -50,6 +47,7 @@
5047
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
5148
<template v-if="showActionsOverlay" #top-left>
5249
<MediaAssetActions
50+
:show-delete-button="showDeleteButton ?? true"
5351
@menu-state-changed="isMenuOpen = $event"
5452
@inspect="handleZoomClick"
5553
@asset-deleted="handleAssetDelete"
@@ -174,12 +172,20 @@ function getBottomComponent(kind: MediaKind) {
174172
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
175173
}
176174
177-
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
175+
const {
176+
asset,
177+
loading,
178+
selected,
179+
showOutputCount,
180+
outputCount,
181+
showDeleteButton
182+
} = defineProps<{
178183
asset?: AssetItem
179184
loading?: boolean
180185
selected?: boolean
181186
showOutputCount?: boolean
182187
outputCount?: number
188+
showDeleteButton?: boolean
183189
}>()
184190
185191
const emit = defineEmits<{
@@ -312,12 +318,6 @@ const showFileFormatChip = computed(
312318
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
313319
)
314320
315-
const handleCardClick = () => {
316-
if (adaptedAsset.value) {
317-
actions.selectAsset(adaptedAsset.value)
318-
}
319-
}
320-
321321
const handleOverlayMouseEnter = () => {
322322
isOverlayHovered.value = true
323323
}

0 commit comments

Comments
 (0)