mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
Add asset deletion progress indicator (#7906)
## Summary Currently the delay between user action and visual response is too long and it looks like it hasn't worked. This adds a visual indicator that the action is processing. ## Changes - add loading indicator while asset is deleting ## Screenshots (if applicable) (Artifically delayed deletion) https://github.com/user-attachments/assets/a9a8b9fe-896d-4666-b643-ec8b990f6444 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7906-Add-asset-deletion-progress-indicator-2e26d73d365081ed82b8e770ba3d0615) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Jin Yi <jin12cc@gmail.com>
This commit is contained in:
49
src/components/common/LoadingOverlay.vue
Normal file
49
src/components/common/LoadingOverlay.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="grid place-items-center">
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'col-start-1 row-start-1 animate-spin rounded-full border-muted-foreground border-t-base-foreground',
|
||||||
|
spinnerSizeClass
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div class="col-start-1 row-start-1">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="loadingMessage" class="mt-4 text-lg text-base-foreground">
|
||||||
|
{{ loadingMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const { size = 'md' } = defineProps<{
|
||||||
|
loading: boolean
|
||||||
|
loadingMessage?: string
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const spinnerSizeClass = computed(() => {
|
||||||
|
switch (size) {
|
||||||
|
case 'sm':
|
||||||
|
return 'h-6 w-6 border-2'
|
||||||
|
case 'md':
|
||||||
|
default:
|
||||||
|
return 'h-12 w-12 border-4'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Transition name="fade">
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<div class="spinner" />
|
|
||||||
<div class="mt-4 text-lg text-base-foreground">
|
|
||||||
{{ loadingMessage }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
loading: boolean
|
|
||||||
loadingMessage: string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.spinner {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border: 4px solid #f3f3f3;
|
|
||||||
border-top: 4px solid #3498db;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -63,39 +63,47 @@
|
|||||||
@approach-end="emit('approach-end')"
|
@approach-end="emit('approach-end')"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<AssetsListItem
|
<div class="relative">
|
||||||
role="button"
|
<LoadingOverlay
|
||||||
tabindex="0"
|
:loading="assetsStore.isAssetDeleting(item.asset.id)"
|
||||||
:aria-label="
|
size="sm"
|
||||||
t('assetBrowser.ariaLabel.assetCard', {
|
>
|
||||||
name: item.asset.name,
|
<i class="pi pi-trash text-xs" />
|
||||||
type: getMediaTypeFromFilename(item.asset.name)
|
</LoadingOverlay>
|
||||||
})
|
<AssetsListItem
|
||||||
"
|
role="button"
|
||||||
:class="getAssetCardClass(isSelected(item.asset.id))"
|
tabindex="0"
|
||||||
:preview-url="item.asset.preview_url"
|
:aria-label="
|
||||||
:preview-alt="item.asset.name"
|
t('assetBrowser.ariaLabel.assetCard', {
|
||||||
:icon-name="
|
name: item.asset.name,
|
||||||
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
|
type: getMediaTypeFromFilename(item.asset.name)
|
||||||
"
|
})
|
||||||
:primary-text="getAssetPrimaryText(item.asset)"
|
"
|
||||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
:class="getAssetCardClass(isSelected(item.asset.id))"
|
||||||
@mouseenter="onAssetEnter(item.asset.id)"
|
:preview-url="item.asset.preview_url"
|
||||||
@mouseleave="onAssetLeave(item.asset.id)"
|
:preview-alt="item.asset.name"
|
||||||
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
:icon-name="
|
||||||
@click.stop="emit('select-asset', item.asset)"
|
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
|
||||||
>
|
"
|
||||||
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
:primary-text="getAssetPrimaryText(item.asset)"
|
||||||
<Button
|
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||||
variant="secondary"
|
@mouseenter="onAssetEnter(item.asset.id)"
|
||||||
size="icon"
|
@mouseleave="onAssetLeave(item.asset.id)"
|
||||||
:aria-label="t('mediaAsset.actions.moreOptions')"
|
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
||||||
@click.stop="emit('context-menu', $event, item.asset)"
|
@click.stop="emit('select-asset', item.asset)"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--ellipsis] size-4" />
|
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
||||||
</Button>
|
<Button
|
||||||
</template>
|
variant="secondary"
|
||||||
</AssetsListItem>
|
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>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</VirtualGrid>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,6 +113,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
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 { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||||
|
import { useAssetsStore } from '@/stores/assetsStore'
|
||||||
import { isActiveJobState } from '@/utils/queueUtil'
|
import { isActiveJobState } from '@/utils/queueUtil'
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -134,6 +144,8 @@ const {
|
|||||||
assetType?: 'input' | 'output'
|
assetType?: 'input' | 'output'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const assetsStore = useAssetsStore()
|
||||||
|
|
||||||
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: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ const {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
downloadMultipleAssets,
|
downloadMultipleAssets,
|
||||||
deleteMultipleAssets,
|
deleteAssets,
|
||||||
addMultipleToWorkflow,
|
addMultipleToWorkflow,
|
||||||
openMultipleWorkflows,
|
openMultipleWorkflows,
|
||||||
exportMultipleWorkflows
|
exportMultipleWorkflows
|
||||||
@@ -495,8 +495,9 @@ const handleBulkDownload = (assets: AssetItem[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkDelete = async (assets: AssetItem[]) => {
|
const handleBulkDelete = async (assets: AssetItem[]) => {
|
||||||
await deleteMultipleAssets(assets)
|
if (await deleteAssets(assets)) {
|
||||||
clearSelection()
|
clearSelection()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClearQueue = async () => {
|
const handleClearQueue = async () => {
|
||||||
@@ -524,6 +525,17 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
|
|||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDownloadSelected = () => {
|
||||||
|
downloadMultipleAssets(selectedAssets.value)
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSelected = async () => {
|
||||||
|
if (await deleteAssets(selectedAssets.value)) {
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleZoomClick = (asset: AssetItem) => {
|
const handleZoomClick = (asset: AssetItem) => {
|
||||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
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 () => {
|
const handleApproachEnd = useDebounceFn(async () => {
|
||||||
if (
|
if (
|
||||||
activeTab.value === 'output' &&
|
activeTab.value === 'output' &&
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const mediaAssetActionsMock = {
|
const mediaAssetActionsMock = {
|
||||||
confirmDelete: vi.fn()
|
deleteAssets: vi.fn()
|
||||||
}
|
}
|
||||||
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
|
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
|
||||||
useMediaAssetActions: () => mediaAssetActionsMock
|
useMediaAssetActions: () => mediaAssetActionsMock
|
||||||
@@ -198,7 +198,7 @@ describe('useJobMenu', () => {
|
|||||||
}))
|
}))
|
||||||
queueStoreMock.update.mockResolvedValue(undefined)
|
queueStoreMock.update.mockResolvedValue(undefined)
|
||||||
queueStoreMock.delete.mockResolvedValue(undefined)
|
queueStoreMock.delete.mockResolvedValue(undefined)
|
||||||
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
|
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
|
||||||
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
|
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
|
||||||
task,
|
task,
|
||||||
output
|
output
|
||||||
@@ -666,7 +666,7 @@ describe('useJobMenu', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('deletes preview asset when confirmed', async () => {
|
it('deletes preview asset when confirmed', async () => {
|
||||||
mediaAssetActionsMock.confirmDelete.mockResolvedValue(true)
|
mediaAssetActionsMock.deleteAssets.mockResolvedValue(true)
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
|
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
|
||||||
const taskRef = { previewOutput: preview }
|
const taskRef = { previewOutput: preview }
|
||||||
@@ -681,7 +681,7 @@ describe('useJobMenu', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('does not refresh queue when delete cancelled', async () => {
|
it('does not refresh queue when delete cancelled', async () => {
|
||||||
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
|
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
setCurrentItem(
|
setCurrentItem(
|
||||||
createJobItem({
|
createJobItem({
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ export function useJobMenu(
|
|||||||
if (!task || !preview) return
|
if (!task || !preview) return
|
||||||
|
|
||||||
const asset = mapTaskOutputToAssetItem(task, preview)
|
const asset = mapTaskOutputToAssetItem(task, preview)
|
||||||
const success = await mediaAssetActions.confirmDelete(asset)
|
const confirmed = await mediaAssetActions.deleteAssets(asset)
|
||||||
if (success) {
|
if (confirmed) {
|
||||||
await queueStore.update()
|
await queueStore.update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
@image-loaded="handleImageLoaded"
|
@image-loaded="handleImageLoaded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LoadingOverlay :loading="isDeleting">
|
||||||
|
<i class="icon-[lucide--trash-2] size-5" />
|
||||||
|
</LoadingOverlay>
|
||||||
|
|
||||||
<!-- Action buttons overlay (top-left) -->
|
<!-- Action buttons overlay (top-left) -->
|
||||||
<div
|
<div
|
||||||
v-if="showActionsOverlay"
|
v-if="showActionsOverlay"
|
||||||
@@ -130,7 +134,9 @@ 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'
|
||||||
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useAssetsStore } from '@/stores/assetsStore'
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatSize,
|
formatSize,
|
||||||
@@ -167,6 +173,13 @@ const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
|||||||
outputCount?: number
|
outputCount?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const assetsStore = useAssetsStore()
|
||||||
|
|
||||||
|
// Get deletion state from store
|
||||||
|
const isDeleting = computed(() =>
|
||||||
|
asset ? assetsStore.isAssetDeleting(asset.id) : false
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
click: []
|
click: []
|
||||||
zoom: [asset: AssetItem]
|
zoom: [asset: AssetItem]
|
||||||
@@ -252,7 +265,7 @@ const metaInfo = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const showActionsOverlay = computed(() => {
|
const showActionsOverlay = computed(() => {
|
||||||
if (loading || !asset) return false
|
if (loading || !asset || isDeleting.value) return false
|
||||||
return isHovered.value || selected || isVideoPlaying.value
|
return isHovered.value || selected || isVideoPlaying.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -247,8 +247,8 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
|||||||
icon: 'icon-[lucide--trash-2]',
|
icon: 'icon-[lucide--trash-2]',
|
||||||
command: async () => {
|
command: async () => {
|
||||||
if (asset) {
|
if (asset) {
|
||||||
const success = await actions.confirmDelete(asset)
|
const confirmed = await actions.deleteAssets(asset)
|
||||||
if (success) {
|
if (confirmed) {
|
||||||
emit('asset-deleted')
|
emit('asset-deleted')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<boolean> => {
|
|
||||||
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 copyJobId = async (asset?: AssetItem) => {
|
||||||
const targetAsset = asset ?? mediaContext?.asset.value
|
const targetAsset = asset ?? mediaContext?.asset.value
|
||||||
if (!targetAsset) return
|
if (!targetAsset) return
|
||||||
@@ -580,30 +515,44 @@ export function useMediaAssetActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete multiple assets with confirmation dialog
|
* Show confirmation dialog and delete asset(s) if confirmed
|
||||||
* @param assets Array of assets to delete
|
* @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[]) => {
|
const deleteAssets = async (
|
||||||
if (!assets || assets.length === 0) return
|
assets: AssetItem | AssetItem[]
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const assetArray = Array.isArray(assets) ? assets : [assets]
|
||||||
|
if (assetArray.length === 0) return false
|
||||||
|
|
||||||
const assetsStore = useAssetsStore()
|
const assetsStore = useAssetsStore()
|
||||||
|
const isSingle = assetArray.length === 1
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise((resolve) => {
|
||||||
dialogStore.showDialog({
|
dialogStore.showDialog({
|
||||||
key: 'delete-multiple-assets-confirmation',
|
key: 'delete-assets-confirmation',
|
||||||
title: t('mediaAsset.deleteSelectedTitle'),
|
title: isSingle
|
||||||
|
? t('mediaAsset.deleteAssetTitle')
|
||||||
|
: t('mediaAsset.deleteSelectedTitle'),
|
||||||
component: ConfirmationDialogContent,
|
component: ConfirmationDialogContent,
|
||||||
props: {
|
props: {
|
||||||
message: t('mediaAsset.deleteSelectedDescription', {
|
message: isSingle
|
||||||
count: assets.length
|
? t('mediaAsset.deleteAssetDescription')
|
||||||
}),
|
: t('mediaAsset.deleteSelectedDescription', {
|
||||||
|
count: assetArray.length
|
||||||
|
}),
|
||||||
type: 'delete',
|
type: 'delete',
|
||||||
itemList: assets.map((asset) => asset.name),
|
itemList: assetArray.map((asset) => asset.name),
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
|
// Show loading overlay for all assets being deleted
|
||||||
|
assetArray.forEach((asset) =>
|
||||||
|
assetsStore.setAssetDeleting(asset.id, true)
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete all assets using Promise.allSettled to track individual results
|
// Delete all assets using Promise.allSettled to track individual results
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
assets.map((asset) =>
|
assetArray.map((asset) =>
|
||||||
deleteAssetApi(asset, getAssetType(asset))
|
deleteAssetApi(asset, getAssetType(asset))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -617,16 +566,16 @@ export function useMediaAssetActions() {
|
|||||||
// Log failed deletions for debugging
|
// Log failed deletions for debugging
|
||||||
failed.forEach((result, index) => {
|
failed.forEach((result, index) => {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Failed to delete asset ${assets[index].name}:`,
|
`Failed to delete asset ${assetArray[index].name}:`,
|
||||||
result.reason
|
result.reason
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update stores after deletions
|
// Update stores after deletions
|
||||||
const hasOutputAssets = assets.some(
|
const hasOutputAssets = assetArray.some(
|
||||||
(a) => getAssetType(a) === 'output'
|
(a) => getAssetType(a) === 'output'
|
||||||
)
|
)
|
||||||
const hasInputAssets = assets.some(
|
const hasInputAssets = assetArray.some(
|
||||||
(a) => getAssetType(a) === 'input'
|
(a) => getAssetType(a) === 'input'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -639,25 +588,27 @@ export function useMediaAssetActions() {
|
|||||||
|
|
||||||
// Show appropriate feedback based on results
|
// Show appropriate feedback based on results
|
||||||
if (failed.length === 0) {
|
if (failed.length === 0) {
|
||||||
// All succeeded
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('g.success'),
|
summary: t('g.success'),
|
||||||
detail: t('mediaAsset.selection.assetsDeletedSuccessfully', {
|
detail: isSingle
|
||||||
count: succeeded
|
? t('mediaAsset.assetDeletedSuccessfully')
|
||||||
}),
|
: t('mediaAsset.selection.assetsDeletedSuccessfully', {
|
||||||
|
count: succeeded
|
||||||
|
}),
|
||||||
life: 2000
|
life: 2000
|
||||||
})
|
})
|
||||||
} else if (succeeded === 0) {
|
} else if (succeeded === 0) {
|
||||||
// All failed
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('g.error'),
|
summary: t('g.error'),
|
||||||
detail: t('mediaAsset.selection.failedToDeleteAssets'),
|
detail: isSingle
|
||||||
|
? t('mediaAsset.failedToDeleteAsset')
|
||||||
|
: t('mediaAsset.selection.failedToDeleteAssets'),
|
||||||
life: 3000
|
life: 3000
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Partial success
|
// Partial success (only possible with multiple assets)
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'warn',
|
severity: 'warn',
|
||||||
summary: t('g.warning'),
|
summary: t('g.warning'),
|
||||||
@@ -673,15 +624,22 @@ export function useMediaAssetActions() {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('g.error'),
|
summary: t('g.error'),
|
||||||
detail: t('mediaAsset.selection.failedToDeleteAssets'),
|
detail: isSingle
|
||||||
|
? t('mediaAsset.failedToDeleteAsset')
|
||||||
|
: t('mediaAsset.selection.failedToDeleteAssets'),
|
||||||
life: 3000
|
life: 3000
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
// Hide loading overlay for all assets
|
||||||
|
assetArray.forEach((asset) =>
|
||||||
|
assetsStore.setAssetDeleting(asset.id, false)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve()
|
resolve(true)
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
resolve()
|
resolve(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -691,9 +649,7 @@ export function useMediaAssetActions() {
|
|||||||
return {
|
return {
|
||||||
downloadAsset,
|
downloadAsset,
|
||||||
downloadMultipleAssets,
|
downloadMultipleAssets,
|
||||||
confirmDelete,
|
deleteAssets,
|
||||||
deleteAsset,
|
|
||||||
deleteMultipleAssets,
|
|
||||||
copyJobId,
|
copyJobId,
|
||||||
addWorkflow,
|
addWorkflow,
|
||||||
addMultipleToWorkflow,
|
addMultipleToWorkflow,
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ async function rerun(e: Event) {
|
|||||||
{
|
{
|
||||||
icon: 'icon-[lucide--trash-2]',
|
icon: 'icon-[lucide--trash-2]',
|
||||||
label: t('queue.jobMenu.deleteAsset'),
|
label: t('queue.jobMenu.deleteAsset'),
|
||||||
action: () => mediaActions.confirmDelete(selectedItem!)
|
action: () => mediaActions.deleteAssets(selectedItem!)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]"
|
]"
|
||||||
|
|||||||
@@ -91,6 +91,21 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
const assetDownloadStore = useAssetDownloadStore()
|
const assetDownloadStore = useAssetDownloadStore()
|
||||||
const modelToNodeStore = useModelToNodeStore()
|
const modelToNodeStore = useModelToNodeStore()
|
||||||
|
|
||||||
|
// Track assets currently being deleted (for loading overlay)
|
||||||
|
const deletingAssetIds = shallowReactive(new Set<string>())
|
||||||
|
|
||||||
|
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
|
// Pagination state
|
||||||
const historyOffset = ref(0)
|
const historyOffset = ref(0)
|
||||||
const hasMoreHistory = ref(true)
|
const hasMoreHistory = ref(true)
|
||||||
@@ -618,6 +633,11 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
hasMoreHistory,
|
hasMoreHistory,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
|
|
||||||
|
// Deletion tracking
|
||||||
|
deletingAssetIds,
|
||||||
|
setAssetDeleting,
|
||||||
|
isAssetDeleting,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
updateInputs,
|
updateInputs,
|
||||||
updateHistory,
|
updateHistory,
|
||||||
|
|||||||
Reference in New Issue
Block a user