-
+
+
+
+
+
+
+ {{
+ $t('mediaAsset.selection.selectedCount', { count: selectedCount })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -91,9 +149,10 @@
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index f95421968..a1f4a608a 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -1779,6 +1779,8 @@
"mediaAsset": {
"deleteAssetTitle": "Delete this asset?",
"deleteAssetDescription": "This asset will be permanently removed.",
+ "deleteSelectedTitle": "Delete selected assets?",
+ "deleteSelectedDescription": "{count} asset(s) will be permanently removed.",
"assetDeletedSuccessfully": "Asset deleted successfully",
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
"failedToDeleteAsset": "Failed to delete asset",
@@ -1787,6 +1789,16 @@
"jobIdCopyFailed": "Failed to copy Job ID",
"copied": "Copied",
"error": "Error"
+ },
+ "selection": {
+ "selectedCount": "Assets Selected: {count}",
+ "deselectAll": "Deselect all",
+ "downloadSelected": "Download",
+ "deleteSelected": "Delete",
+ "downloadStarted": "Downloading {count} files...",
+ "downloadsStarted": "Started downloading {count} file(s)",
+ "assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
+ "failedToDeleteAssets": "Failed to delete selected assets"
}
},
"actionbar": {
diff --git a/src/platform/assets/components/MediaAssetActions.vue b/src/platform/assets/components/MediaAssetActions.vue
index e2c6d1064..a4c9a6b55 100644
--- a/src/platform/assets/components/MediaAssetActions.vue
+++ b/src/platform/assets/components/MediaAssetActions.vue
@@ -1,6 +1,6 @@
-
+
@@ -14,6 +14,7 @@
@@ -34,6 +35,10 @@ 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: []
@@ -47,10 +52,12 @@ const assetType = computed(() => {
return context?.value?.type || asset.value?.tags?.[0] || 'output'
})
-const showDeleteButton = computed(() => {
- return (
+const shouldShowDeleteButton = computed(() => {
+ const propAllows = showDeleteButton ?? true
+ const typeAllows =
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
- )
+
+ return propAllows && typeAllows
})
const handleDelete = async () => {
diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue
index d3f7fe4a4..64f0c5b78 100644
--- a/src/platform/assets/components/MediaAssetCard.vue
+++ b/src/platform/assets/components/MediaAssetCard.vue
@@ -15,9 +15,6 @@
variant="ghost"
rounded="lg"
:class="containerClasses"
- @click="handleCardClick"
- @keydown.enter="handleCardClick"
- @keydown.space.prevent="handleCardClick"
>
()
const emit = defineEmits<{
@@ -312,12 +318,6 @@ const showFileFormatChip = computed(
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
-const handleCardClick = () => {
- if (adaptedAsset.value) {
- actions.selectAsset(adaptedAsset.value)
- }
-}
-
const handleOverlayMouseEnter = () => {
isOverlayHovered.value = true
}
diff --git a/src/platform/assets/components/MediaAssetMoreMenu.vue b/src/platform/assets/components/MediaAssetMoreMenu.vue
index 76d193f8e..818f69d96 100644
--- a/src/platform/assets/components/MediaAssetMoreMenu.vue
+++ b/src/platform/assets/components/MediaAssetMoreMenu.vue
@@ -75,10 +75,10 @@
-
+
void
+ showDeleteButton?: boolean
}>()
const emit = defineEmits<{
@@ -124,13 +125,12 @@ const showCopyJobId = computed(() => {
return assetType.value !== 'input'
})
-// Delete button should be shown for:
-// - All output files (can be deleted via history)
-// - Input files only in cloud environment
-const showDeleteButton = computed(() => {
- return (
+const shouldShowDeleteButton = computed(() => {
+ const propAllows = showDeleteButton ?? true
+ const typeAllows =
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
- )
+
+ return propAllows && typeAllows
})
const handleInspect = () => {
diff --git a/src/platform/assets/composables/useAssetSelection.ts b/src/platform/assets/composables/useAssetSelection.ts
new file mode 100644
index 000000000..5848b9d8f
--- /dev/null
+++ b/src/platform/assets/composables/useAssetSelection.ts
@@ -0,0 +1,129 @@
+import { useKeyModifier } from '@vueuse/core'
+import { computed, ref } from 'vue'
+
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
+
+export function useAssetSelection() {
+ const selectionStore = useAssetSelectionStore()
+
+ // Track whether the asset selection is active (e.g., when sidebar is open)
+ const isActive = ref(true)
+
+ // Key modifiers - raw values
+ const shiftKeyRaw = useKeyModifier('Shift')
+ const ctrlKeyRaw = useKeyModifier('Control')
+ const metaKeyRaw = useKeyModifier('Meta')
+
+ // Only respond to key modifiers when active
+ const shiftKey = computed(() => isActive.value && shiftKeyRaw.value)
+ const ctrlKey = computed(() => isActive.value && ctrlKeyRaw.value)
+ const metaKey = computed(() => isActive.value && metaKeyRaw.value)
+ const cmdOrCtrlKey = computed(() => ctrlKey.value || metaKey.value)
+
+ /**
+ * Handle asset click with modifier keys for selection
+ * @param asset The clicked asset
+ * @param index The index of the clicked asset in the current list
+ * @param allAssets All assets in the current view for range selection
+ */
+ function handleAssetClick(
+ asset: AssetItem,
+ index: number,
+ allAssets: AssetItem[]
+ ) {
+ // Input validation
+ if (!asset?.id || index < 0 || index >= allAssets.length) {
+ console.warn('Invalid asset selection parameters')
+ return
+ }
+
+ const assetId = asset.id
+
+ // Shift + Click: Range selection
+ if (shiftKey.value && selectionStore.lastSelectedIndex >= 0) {
+ const start = Math.min(selectionStore.lastSelectedIndex, index)
+ const end = Math.max(selectionStore.lastSelectedIndex, index)
+
+ // Batch operation for better performance
+ const rangeIds = allAssets.slice(start, end + 1).map((a) => a.id)
+ const existingIds = Array.from(selectionStore.selectedAssetIds)
+ const combinedIds = [...new Set([...existingIds, ...rangeIds])]
+
+ // Single update instead of multiple forEach operations
+ selectionStore.setSelection(combinedIds)
+
+ // Don't update lastSelectedIndex for shift selection
+ return
+ }
+
+ // Ctrl/Cmd + Click: Toggle individual selection
+ if (cmdOrCtrlKey.value) {
+ selectionStore.toggleSelection(assetId)
+ selectionStore.setLastSelectedIndex(index)
+ return
+ }
+
+ // Normal Click: Single selection
+ selectionStore.clearSelection()
+ selectionStore.addToSelection(assetId)
+ selectionStore.setLastSelectedIndex(index)
+ }
+
+ /**
+ * Select all assets in the current view
+ */
+ function selectAll(allAssets: AssetItem[]) {
+ const allIds = allAssets.map((a) => a.id)
+ selectionStore.setSelection(allIds)
+ if (allAssets.length > 0) {
+ selectionStore.setLastSelectedIndex(allAssets.length - 1)
+ }
+ }
+
+ /**
+ * Get the actual asset objects for selected IDs
+ */
+ function getSelectedAssets(allAssets: AssetItem[]): AssetItem[] {
+ return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
+ }
+
+ /**
+ * Activate key event listeners (when sidebar opens)
+ */
+ function activate() {
+ isActive.value = true
+ }
+
+ /**
+ * Deactivate key event listeners (when sidebar closes)
+ */
+ function deactivate() {
+ isActive.value = false
+ // Reset selection state to ensure clean state when deactivated
+ selectionStore.reset()
+ }
+
+ return {
+ // Selection state
+ selectedIds: computed(() => selectionStore.selectedAssetIds),
+ selectedCount: computed(() => selectionStore.selectedCount),
+ hasSelection: computed(() => selectionStore.hasSelection),
+ isSelected: (assetId: string) => selectionStore.isSelected(assetId),
+
+ // Selection actions
+ handleAssetClick,
+ selectAll,
+ clearSelection: () => selectionStore.clearSelection(),
+ getSelectedAssets,
+ reset: () => selectionStore.reset(),
+
+ // Lifecycle management
+ activate,
+ deactivate,
+
+ // Key states (for UI feedback)
+ shiftKey,
+ cmdOrCtrlKey
+ }
+}
diff --git a/src/platform/assets/composables/useAssetSelectionStore.ts b/src/platform/assets/composables/useAssetSelectionStore.ts
new file mode 100644
index 000000000..08edb7bf8
--- /dev/null
+++ b/src/platform/assets/composables/useAssetSelectionStore.ts
@@ -0,0 +1,81 @@
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+
+export const useAssetSelectionStore = defineStore('assetSelection', () => {
+ // State
+ const selectedAssetIds = ref>(new Set())
+ const lastSelectedIndex = ref(-1)
+
+ // Getters
+ const selectedCount = computed(() => selectedAssetIds.value.size)
+ const hasSelection = computed(() => selectedAssetIds.value.size > 0)
+ const selectedIdsArray = computed(() => Array.from(selectedAssetIds.value))
+
+ // Actions
+ function addToSelection(assetId: string) {
+ selectedAssetIds.value.add(assetId)
+ }
+
+ function removeFromSelection(assetId: string) {
+ selectedAssetIds.value.delete(assetId)
+ }
+
+ function setSelection(assetIds: string[]) {
+ // Only update if there's an actual change to prevent unnecessary re-renders
+ const newSet = new Set(assetIds)
+ if (
+ newSet.size !== selectedAssetIds.value.size ||
+ !assetIds.every((id) => selectedAssetIds.value.has(id))
+ ) {
+ selectedAssetIds.value = newSet
+ }
+ }
+
+ function clearSelection() {
+ selectedAssetIds.value.clear()
+ lastSelectedIndex.value = -1
+ }
+
+ function toggleSelection(assetId: string) {
+ if (isSelected(assetId)) {
+ removeFromSelection(assetId)
+ } else {
+ addToSelection(assetId)
+ }
+ }
+
+ function isSelected(assetId: string): boolean {
+ return selectedAssetIds.value.has(assetId)
+ }
+
+ function setLastSelectedIndex(index: number) {
+ lastSelectedIndex.value = index
+ }
+
+ // Reset function for cleanup
+ function reset() {
+ selectedAssetIds.value.clear()
+ lastSelectedIndex.value = -1
+ }
+
+ return {
+ // State
+ selectedAssetIds: computed(() => selectedAssetIds.value),
+ lastSelectedIndex: computed(() => lastSelectedIndex.value),
+
+ // Getters
+ selectedCount,
+ hasSelection,
+ selectedIdsArray,
+
+ // Actions
+ addToSelection,
+ removeFromSelection,
+ setSelection,
+ clearSelection,
+ toggleSelection,
+ isSelected,
+ setLastSelectedIndex,
+ reset
+ }
+})
diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts
index c7c2e5541..0ccc82225 100644
--- a/src/platform/assets/composables/useMediaAssetActions.ts
+++ b/src/platform/assets/composables/useMediaAssetActions.ts
@@ -12,7 +12,6 @@ import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { AssetItem } from '../schemas/assetSchema'
-import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import { assetService } from '../services/assetService'
@@ -21,10 +20,6 @@ export function useMediaAssetActions() {
const dialogStore = useDialogStore()
const mediaContext = inject(MediaAssetKey, null)
- const selectAsset = (asset: AssetMeta) => {
- console.log('Asset selected:', asset)
- }
-
const downloadAsset = () => {
const asset = mediaContext?.asset.value
if (!asset) return
@@ -54,6 +49,42 @@ export function useMediaAssetActions() {
}
}
+ /**
+ * Download multiple assets at once
+ * @param assets Array of assets to download
+ */
+ const downloadMultipleAssets = (assets: AssetItem[]) => {
+ if (!assets || assets.length === 0) return
+
+ try {
+ assets.forEach((asset) => {
+ const assetType = asset.tags?.[0] || 'output'
+ const filename = asset.name
+ const downloadUrl = api.apiURL(
+ `/view?filename=${encodeURIComponent(filename)}&type=${assetType}`
+ )
+ downloadFile(downloadUrl, filename)
+ })
+
+ toast.add({
+ severity: 'success',
+ summary: t('g.success'),
+ detail: t('mediaAsset.selection.downloadsStarted', {
+ count: assets.length
+ }),
+ life: 2000
+ })
+ } catch (error) {
+ console.error('Failed to download assets:', error)
+ toast.add({
+ severity: 'error',
+ summary: t('g.error'),
+ detail: t('g.failedToDownloadImage'),
+ life: 3000
+ })
+ }
+ }
+
/**
* Show confirmation dialog and delete asset if confirmed
* @param asset The asset to delete
@@ -204,11 +235,85 @@ export function useMediaAssetActions() {
console.log('Opening more outputs for asset:', assetId)
}
+ /**
+ * Delete multiple assets with confirmation dialog
+ * @param assets Array of assets to delete
+ */
+ const deleteMultipleAssets = async (assets: AssetItem[]) => {
+ if (!assets || assets.length === 0) return
+
+ const assetsStore = useAssetsStore()
+
+ return new Promise((resolve) => {
+ dialogStore.showDialog({
+ key: 'delete-multiple-assets-confirmation',
+ title: t('mediaAsset.deleteSelectedTitle'),
+ component: ConfirmationDialogContent,
+ props: {
+ message: t('mediaAsset.deleteSelectedDescription', {
+ count: assets.length
+ }),
+ type: 'delete',
+ itemList: assets.map((asset) => asset.name),
+ onConfirm: async () => {
+ try {
+ // Delete all assets
+ await Promise.all(
+ assets.map(async (asset) => {
+ const assetType = asset.tags?.[0] || 'output'
+ if (assetType === 'output') {
+ const promptId =
+ asset.id ||
+ getOutputAssetMetadata(asset.user_metadata)?.promptId
+ if (promptId) {
+ await api.deleteItem('history', promptId)
+ }
+ } else if (isCloud) {
+ await assetService.deleteAsset(asset.id)
+ }
+ })
+ )
+
+ // Update stores after deletions
+ await assetsStore.updateHistory()
+ if (assets.some((a) => a.tags?.[0] === 'input')) {
+ await assetsStore.updateInputs()
+ }
+
+ toast.add({
+ severity: 'success',
+ summary: t('g.success'),
+ detail: t('mediaAsset.selection.assetsDeletedSuccessfully', {
+ count: assets.length
+ }),
+ life: 2000
+ })
+ } catch (error) {
+ console.error('Failed to delete assets:', error)
+ toast.add({
+ severity: 'error',
+ summary: t('g.error'),
+ detail: t('mediaAsset.selection.failedToDeleteAssets'),
+ life: 3000
+ })
+ }
+
+ resolve()
+ },
+ onCancel: () => {
+ resolve()
+ }
+ }
+ })
+ })
+ }
+
return {
- selectAsset,
downloadAsset,
+ downloadMultipleAssets,
confirmDelete,
deleteAsset,
+ deleteMultipleAssets,
playAsset,
copyJobId,
addWorkflow,