diff --git a/src/components/common/LoadingOverlay.vue b/src/components/common/LoadingOverlay.vue new file mode 100644 index 000000000..ccc34908e --- /dev/null +++ b/src/components/common/LoadingOverlay.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/components/load3d/Load3DScene.vue b/src/components/load3d/Load3DScene.vue index cf497dea5..de2afbdb2 100644 --- a/src/components/load3d/Load3DScene.vue +++ b/src/components/load3d/Load3DScene.vue @@ -31,7 +31,7 @@ - - diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.vue b/src/components/sidebar/tabs/AssetsSidebarListView.vue index b39d982a5..1e2bbe2d9 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.vue +++ b/src/components/sidebar/tabs/AssetsSidebarListView.vue @@ -63,39 +63,47 @@ @approach-end="emit('approach-end')" > @@ -105,6 +113,7 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' +import LoadingOverlay from '@/components/common/LoadingOverlay.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue' import Button from '@/components/ui/button/Button.vue' import { useJobActions } from '@/composables/queue/useJobActions' @@ -114,6 +123,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil' +import { useAssetsStore } from '@/stores/assetsStore' import { isActiveJobState } from '@/utils/queueUtil' import { formatDuration, @@ -134,6 +144,8 @@ const { assetType?: 'input' | 'output' }>() +const assetsStore = useAssetsStore() + const emit = defineEmits<{ (e: 'select-asset', asset: AssetItem): void (e: 'context-menu', event: MouseEvent, asset: AssetItem): void diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 58b686516..e91238a6b 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -331,7 +331,7 @@ const { const { downloadMultipleAssets, - deleteMultipleAssets, + deleteAssets, addMultipleToWorkflow, openMultipleWorkflows, exportMultipleWorkflows @@ -495,8 +495,9 @@ const handleBulkDownload = (assets: AssetItem[]) => { } const handleBulkDelete = async (assets: AssetItem[]) => { - await deleteMultipleAssets(assets) - clearSelection() + if (await deleteAssets(assets)) { + clearSelection() + } } const handleClearQueue = async () => { @@ -524,6 +525,17 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => { clearSelection() } +const handleDownloadSelected = () => { + downloadMultipleAssets(selectedAssets.value) + clearSelection() +} + +const handleDeleteSelected = async () => { + if (await deleteAssets(selectedAssets.value)) { + clearSelection() + } +} + const handleZoomClick = (asset: AssetItem) => { const mediaType = getMediaTypeFromFilename(asset.name) @@ -672,16 +684,6 @@ const copyJobId = async () => { } } -const handleDownloadSelected = () => { - downloadMultipleAssets(selectedAssets.value) - clearSelection() -} - -const handleDeleteSelected = async () => { - await deleteMultipleAssets(selectedAssets.value) - clearSelection() -} - const handleApproachEnd = useDebounceFn(async () => { if ( activeTab.value === 'output' && diff --git a/src/composables/queue/useJobMenu.test.ts b/src/composables/queue/useJobMenu.test.ts index e07889022..eea3d9232 100644 --- a/src/composables/queue/useJobMenu.test.ts +++ b/src/composables/queue/useJobMenu.test.ts @@ -40,7 +40,7 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({ })) const mediaAssetActionsMock = { - confirmDelete: vi.fn() + deleteAssets: vi.fn() } vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({ useMediaAssetActions: () => mediaAssetActionsMock @@ -198,7 +198,7 @@ describe('useJobMenu', () => { })) queueStoreMock.update.mockResolvedValue(undefined) queueStoreMock.delete.mockResolvedValue(undefined) - mediaAssetActionsMock.confirmDelete.mockResolvedValue(false) + mediaAssetActionsMock.deleteAssets.mockResolvedValue(false) mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({ task, output @@ -666,7 +666,7 @@ describe('useJobMenu', () => { }) it('deletes preview asset when confirmed', async () => { - mediaAssetActionsMock.confirmDelete.mockResolvedValue(true) + mediaAssetActionsMock.deleteAssets.mockResolvedValue(true) const { jobMenuEntries } = mountJobMenu() const preview = { filename: 'foo', subfolder: 'bar', type: 'output' } const taskRef = { previewOutput: preview } @@ -681,7 +681,7 @@ describe('useJobMenu', () => { }) it('does not refresh queue when delete cancelled', async () => { - mediaAssetActionsMock.confirmDelete.mockResolvedValue(false) + mediaAssetActionsMock.deleteAssets.mockResolvedValue(false) const { jobMenuEntries } = mountJobMenu() setCurrentItem( createJobItem({ diff --git a/src/composables/queue/useJobMenu.ts b/src/composables/queue/useJobMenu.ts index b50a24846..bdf7033f6 100644 --- a/src/composables/queue/useJobMenu.ts +++ b/src/composables/queue/useJobMenu.ts @@ -210,8 +210,8 @@ export function useJobMenu( if (!task || !preview) return const asset = mapTaskOutputToAssetItem(task, preview) - const success = await mediaAssetActions.confirmDelete(asset) - if (success) { + const confirmed = await mediaAssetActions.deleteAssets(asset) + if (confirmed) { await queueStore.update() } } diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index 8da39c67b..f6179f303 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -48,6 +48,10 @@ @image-loaded="handleImageLoaded" /> + + + +
() +const assetsStore = useAssetsStore() + +// Get deletion state from store +const isDeleting = computed(() => + asset ? assetsStore.isAssetDeleting(asset.id) : false +) + const emit = defineEmits<{ click: [] zoom: [asset: AssetItem] @@ -252,7 +265,7 @@ const metaInfo = computed(() => { }) const showActionsOverlay = computed(() => { - if (loading || !asset) return false + if (loading || !asset || isDeleting.value) return false return isHovered.value || selected || isVideoPlaying.value }) diff --git a/src/platform/assets/components/MediaAssetContextMenu.vue b/src/platform/assets/components/MediaAssetContextMenu.vue index 57c99cf52..35b97fab7 100644 --- a/src/platform/assets/components/MediaAssetContextMenu.vue +++ b/src/platform/assets/components/MediaAssetContextMenu.vue @@ -247,8 +247,8 @@ const contextMenuItems = computed(() => { icon: 'icon-[lucide--trash-2]', command: async () => { if (asset) { - const success = await actions.confirmDelete(asset) - if (success) { + const confirmed = await actions.deleteAssets(asset) + if (confirmed) { emit('asset-deleted') } } diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index 40636b851..98a9444ba 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -134,71 +134,6 @@ export function useMediaAssetActions() { } } - /** - * Show confirmation dialog and delete asset if confirmed - * @param asset The asset to delete - * @returns true if the asset was deleted, false otherwise - */ - const confirmDelete = async (asset: AssetItem): Promise => { - const assetType = getAssetType(asset) - - return new Promise((resolve) => { - dialogStore.showDialog({ - key: 'delete-asset-confirmation', - title: t('mediaAsset.deleteAssetTitle'), - component: ConfirmationDialogContent, - props: { - message: t('mediaAsset.deleteAssetDescription'), - type: 'delete', - itemList: [asset.name], - onConfirm: async () => { - const success = await deleteAsset(asset, assetType) - resolve(success) - }, - onCancel: () => { - resolve(false) - } - } - }) - }) - } - - const deleteAsset = async (asset: AssetItem, assetType: string) => { - const assetsStore = useAssetsStore() - - try { - // Perform the deletion - await deleteAssetApi(asset, assetType) - - // Update the appropriate store based on asset type - if (assetType === 'output') { - await assetsStore.updateHistory() - } else { - await assetsStore.updateInputs() - } - - toast.add({ - severity: 'success', - summary: t('g.success'), - detail: t('mediaAsset.assetDeletedSuccessfully'), - life: 2000 - }) - return true - } catch (error) { - console.error('Failed to delete asset:', error) - const errorMessage = error instanceof Error ? error.message : '' - const isCloudWarning = errorMessage.includes('Cloud') - - toast.add({ - severity: isCloudWarning ? 'warn' : 'error', - summary: isCloudWarning ? t('g.warning') : t('g.error'), - detail: errorMessage || t('mediaAsset.failedToDeleteAsset'), - life: 3000 - }) - return false - } - } - const copyJobId = async (asset?: AssetItem) => { const targetAsset = asset ?? mediaContext?.asset.value if (!targetAsset) return @@ -580,30 +515,44 @@ export function useMediaAssetActions() { } /** - * Delete multiple assets with confirmation dialog - * @param assets Array of assets to delete + * Show confirmation dialog and delete asset(s) if confirmed + * @param assets Single asset or array of assets to delete + * @returns true if user confirmed and deletion was attempted, false if cancelled */ - const deleteMultipleAssets = async (assets: AssetItem[]) => { - if (!assets || assets.length === 0) return + const deleteAssets = async ( + assets: AssetItem | AssetItem[] + ): Promise => { + const assetArray = Array.isArray(assets) ? assets : [assets] + if (assetArray.length === 0) return false const assetsStore = useAssetsStore() + const isSingle = assetArray.length === 1 - return new Promise((resolve) => { + return new Promise((resolve) => { dialogStore.showDialog({ - key: 'delete-multiple-assets-confirmation', - title: t('mediaAsset.deleteSelectedTitle'), + key: 'delete-assets-confirmation', + title: isSingle + ? t('mediaAsset.deleteAssetTitle') + : t('mediaAsset.deleteSelectedTitle'), component: ConfirmationDialogContent, props: { - message: t('mediaAsset.deleteSelectedDescription', { - count: assets.length - }), + message: isSingle + ? t('mediaAsset.deleteAssetDescription') + : t('mediaAsset.deleteSelectedDescription', { + count: assetArray.length + }), type: 'delete', - itemList: assets.map((asset) => asset.name), + itemList: assetArray.map((asset) => asset.name), onConfirm: async () => { + // Show loading overlay for all assets being deleted + assetArray.forEach((asset) => + assetsStore.setAssetDeleting(asset.id, true) + ) + try { // Delete all assets using Promise.allSettled to track individual results const results = await Promise.allSettled( - assets.map((asset) => + assetArray.map((asset) => deleteAssetApi(asset, getAssetType(asset)) ) ) @@ -617,16 +566,16 @@ export function useMediaAssetActions() { // Log failed deletions for debugging failed.forEach((result, index) => { console.warn( - `Failed to delete asset ${assets[index].name}:`, + `Failed to delete asset ${assetArray[index].name}:`, result.reason ) }) // Update stores after deletions - const hasOutputAssets = assets.some( + const hasOutputAssets = assetArray.some( (a) => getAssetType(a) === 'output' ) - const hasInputAssets = assets.some( + const hasInputAssets = assetArray.some( (a) => getAssetType(a) === 'input' ) @@ -639,25 +588,27 @@ export function useMediaAssetActions() { // Show appropriate feedback based on results if (failed.length === 0) { - // All succeeded toast.add({ severity: 'success', summary: t('g.success'), - detail: t('mediaAsset.selection.assetsDeletedSuccessfully', { - count: succeeded - }), + detail: isSingle + ? t('mediaAsset.assetDeletedSuccessfully') + : t('mediaAsset.selection.assetsDeletedSuccessfully', { + count: succeeded + }), life: 2000 }) } else if (succeeded === 0) { - // All failed toast.add({ severity: 'error', summary: t('g.error'), - detail: t('mediaAsset.selection.failedToDeleteAssets'), + detail: isSingle + ? t('mediaAsset.failedToDeleteAsset') + : t('mediaAsset.selection.failedToDeleteAssets'), life: 3000 }) } else { - // Partial success + // Partial success (only possible with multiple assets) toast.add({ severity: 'warn', summary: t('g.warning'), @@ -673,15 +624,22 @@ export function useMediaAssetActions() { toast.add({ severity: 'error', summary: t('g.error'), - detail: t('mediaAsset.selection.failedToDeleteAssets'), + detail: isSingle + ? t('mediaAsset.failedToDeleteAsset') + : t('mediaAsset.selection.failedToDeleteAssets'), life: 3000 }) + } finally { + // Hide loading overlay for all assets + assetArray.forEach((asset) => + assetsStore.setAssetDeleting(asset.id, false) + ) } - resolve() + resolve(true) }, onCancel: () => { - resolve() + resolve(false) } } }) @@ -691,9 +649,7 @@ export function useMediaAssetActions() { return { downloadAsset, downloadMultipleAssets, - confirmDelete, - deleteAsset, - deleteMultipleAssets, + deleteAssets, copyJobId, addWorkflow, addMultipleToWorkflow, diff --git a/src/renderer/extensions/linearMode/LinearPreview.vue b/src/renderer/extensions/linearMode/LinearPreview.vue index 281697138..378a579dc 100644 --- a/src/renderer/extensions/linearMode/LinearPreview.vue +++ b/src/renderer/extensions/linearMode/LinearPreview.vue @@ -144,7 +144,7 @@ async function rerun(e: Event) { { icon: 'icon-[lucide--trash-2]', label: t('queue.jobMenu.deleteAsset'), - action: () => mediaActions.confirmDelete(selectedItem!) + action: () => mediaActions.deleteAssets(selectedItem!) } ] ]" diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 619c2ac26..41fb3e878 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -91,6 +91,21 @@ export const useAssetsStore = defineStore('assets', () => { const assetDownloadStore = useAssetDownloadStore() const modelToNodeStore = useModelToNodeStore() + // Track assets currently being deleted (for loading overlay) + const deletingAssetIds = shallowReactive(new Set()) + + const setAssetDeleting = (assetId: string, isDeleting: boolean) => { + if (isDeleting) { + deletingAssetIds.add(assetId) + } else { + deletingAssetIds.delete(assetId) + } + } + + const isAssetDeleting = (assetId: string): boolean => { + return deletingAssetIds.has(assetId) + } + // Pagination state const historyOffset = ref(0) const hasMoreHistory = ref(true) @@ -618,6 +633,11 @@ export const useAssetsStore = defineStore('assets', () => { hasMoreHistory, isLoadingMore, + // Deletion tracking + deletingAssetIds, + setAssetDeleting, + isAssetDeleting, + // Actions updateInputs, updateHistory,