Files
bookorbit/client/src/components/SelectionActionBar.vue
T
neon 59e3f36f5e fix(auth): enforce library roles for metadata editing
Library access roles had no effect on metadata editing: only the global Edit metadata permission
was checked, so a Viewer could edit every library they could see and an Editor could not be
limited to one library. Require Editor or Owner on the book's library for every metadata write,
including bulk edits, locks, covers, file renames, entity manager changes and author edits, and
hide the matching actions in the web client.

Keep existing access levels when an admin saves a user's library list. The old replace reset every
row to Viewer, which would now silently revoke editing on each user save.

Existing grants are Viewer, so after upgrading, admins must set Editor on each library where a
non-superuser should keep editing metadata. The released iOS app already gates editing on Editor.

Closes #1778
2026-10-14 03:57:51 +00:00

634 lines
26 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref, useSlots, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
BookOpen,
Download,
FileSpreadsheet,
FolderInput,
FolderMinus,
FolderPlus,
ImageDown,
Lock,
Loader2,
Mail,
MoreHorizontal,
Pencil,
RefreshCw,
SquareArrowOutUpRight,
SquarePen,
Star,
Trash2,
Unlock,
X,
} from '@lucide/vue'
import InputWithSuggestions from '@/components/ui/InputWithSuggestions.vue'
import IgnoreCacheCheckbox from '@/features/metadata-cache/components/IgnoreCacheCheckbox.vue'
import type { IgnoreCacheOptions } from '@/features/metadata-cache/lib/metadata-cache'
import { usePublisherSearch, useSeriesNameSearch, useLanguageSearch } from '@/features/book/composables/useMetadataFieldSearch'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { STATUS_ICONS, STATUS_OPTIONS } from '@/features/book/composables/useBookStatus'
import type { ReadStatus } from '@bookorbit/types'
import {
BULK_EDITABLE_ARRAY_FIELDS,
BULK_EDITABLE_FIELD_OPTIONS,
type BulkEditableField,
type BulkEditableValue,
type InFlightOp,
} from '@/features/book/composables/useBookBulkActions'
export type ExportScope = 'primary' | 'all' | 'audio'
const ICON_SIZE = 17
const BTN_ICON = 'text-foreground h-9 w-9 shrink-0 flex items-center justify-center rounded-full transition-colors'
const BTN_DISABLED = 'text-muted-foreground cursor-not-allowed'
const BTN_PRIMARY = 'text-foreground hover:bg-primary hover:text-primary-foreground'
const BTN_MUTED = 'text-foreground hover:bg-muted'
const BTN_DESTRUCTIVE = 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
const BTN_TEXT_PRIMARY =
'flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors'
const BTN_TEXT_CANCEL = 'h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors'
const BTN_TEXT_DESTRUCTIVE =
'h-8 px-3 rounded-full text-sm font-medium text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors'
const DIVIDER = 'w-px h-5 bg-border mx-1 shrink-0'
const props = withDefaults(
defineProps<{
count: number
visible: boolean
inCollection?: boolean
inFlight?: InFlightOp | null
// Selection is query-scoped (all matching across pages), so individual ids aren't enumerable.
queryScoped?: boolean
metadataEditable?: boolean
}>(),
{ metadataEditable: true },
)
const emit = defineEmits<{
'add-to-collection': []
'remove-from-collection': []
edit: []
'edit-individually': []
send: []
download: [scope: ExportScope]
'export-metadata': []
'refresh-metadata': [options: IgnoreCacheOptions]
're-extract-cover': []
'set-status': [status: ReadStatus]
'set-rating': [rating: number | null]
'set-field': [field: BulkEditableField, value: BulkEditableValue]
'lock-metadata': [locked: boolean]
'move-to-library': []
delete: []
exit: []
}>()
const { t } = useI18n()
const { hasPermission, isDemoRestrictedAccount } = usePermissions()
const { search: searchPublisher } = usePublisherSearch()
const { search: searchSeriesName } = useSeriesNameSearch()
const { search: searchLanguage } = useLanguageSearch()
const confirmingDelete = ref(false)
const deleteInput = ref('')
const exportMenuOpen = ref(false)
const ratingMenuOpen = ref(false)
const fieldMenuOpen = ref(false)
const refreshConfirmOpen = ref(false)
const refreshIgnoreCache = ref(false)
const refreshConfirmButton = ref<HTMLButtonElement | null>(null)
const bulkField = ref<BulkEditableField>('publisher')
const fieldValue = ref('')
const slots = useSlots()
const hasCustomContent = computed(() => Boolean(slots.content))
const canBulkActions = computed(() => !isDemoRestrictedAccount.value)
const canDownload = computed(() => hasPermission('library_download') && canBulkActions.value)
const canSetRating = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value)
const canEditMetadata = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value && props.metadataEditable !== false)
const canMoveToLibrary = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value && props.metadataEditable !== false)
const canShowMoreMenu = computed(() => canDownload.value || canEditMetadata.value || canMoveToLibrary.value)
const canShare = computed(() => hasPermission('email_send') || canDownload.value)
const numericFieldSelected = computed(() => bulkField.value === 'publishedYear')
const arrayFieldSelected = computed(() => (BULK_EDITABLE_ARRAY_FIELDS as readonly string[]).includes(bulkField.value))
const fieldValuePlaceholder = computed(() =>
arrayFieldSelected.value ? t('components.selectionActionBar.fieldPlaceholderArray') : t('components.selectionActionBar.fieldPlaceholder'),
)
const typeaheadSearchFn = computed<((q: string) => Promise<string[]>) | null>(() => {
if (bulkField.value === 'seriesName') return searchSeriesName
if (bulkField.value === 'publisher') return searchPublisher
if (bulkField.value === 'language') return searchLanguage
return null
})
const canConfirmDelete = computed(() => props.count <= 50 || deleteInput.value === 'DELETE')
const canApplyFieldValue = computed(() => {
if (!numericFieldSelected.value) return true
const trimmed = fieldValue.value.trim()
return trimmed.length === 0 || !Number.isNaN(Number(trimmed))
})
function onExport(scope: ExportScope) {
emit('download', scope)
exportMenuOpen.value = false
}
function onConfirmDelete() {
if (!canConfirmDelete.value) return
emit('delete')
confirmingDelete.value = false
deleteInput.value = ''
}
function onSetStatus(status: ReadStatus) {
emit('set-status', status)
}
function onSetRating(rating: number | null) {
emit('set-rating', rating)
ratingMenuOpen.value = false
}
function clearRating() {
onSetRating(null)
}
function cancelDelete() {
confirmingDelete.value = false
deleteInput.value = ''
}
function lockAll() {
emit('lock-metadata', true)
}
function unlockAll() {
emit('lock-metadata', false)
}
function openFieldEditor() {
if (props.count === 0) return
fieldMenuOpen.value = true
}
async function onRefreshMetadata() {
if (props.count === 0) return
refreshIgnoreCache.value = false
refreshConfirmOpen.value = true
// The menu item that opened this is gone, so focus would otherwise fall back to the page.
await nextTick()
refreshConfirmButton.value?.focus()
}
function confirmRefreshMetadata() {
emit('refresh-metadata', { ignoreCache: refreshIgnoreCache.value })
refreshConfirmOpen.value = false
}
function cancelRefreshMetadata() {
refreshConfirmOpen.value = false
}
function onReExtractCover() {
if (props.count === 0) return
emit('re-extract-cover')
}
function onMoveToLibrary() {
if (props.count === 0) return
emit('move-to-library')
}
function resetFieldEditor() {
fieldMenuOpen.value = false
bulkField.value = 'publisher'
fieldValue.value = ''
}
function applyFieldEdit() {
if (!canApplyFieldValue.value) return
const trimmed = fieldValue.value.trim()
let value: BulkEditableValue
if (numericFieldSelected.value) {
value = trimmed ? Number(trimmed) : null
} else if (arrayFieldSelected.value) {
value = trimmed
? [
...new Set(
trimmed
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
),
]
: []
} else {
value = trimmed || null
}
emit('set-field', bulkField.value, value)
resetFieldEditor()
}
watch(
() => props.visible,
(v) => {
if (!v) {
confirmingDelete.value = false
exportMenuOpen.value = false
ratingMenuOpen.value = false
refreshConfirmOpen.value = false
deleteInput.value = ''
resetFieldEditor()
}
},
)
</script>
<template>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-4"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-4"
>
<div
v-if="visible"
class="fixed bottom-[max(1.5rem,var(--podcast-mini-player-clearance,0px))] left-1/2 -translate-x-1/2 z-50 max-w-[calc(100svw-24px)] rounded-full bg-card/90 backdrop-blur-xl border border-primary/40 shadow-[0_8px_32px_rgba(0,0,0,0.35)] overflow-hidden"
>
<div class="flex items-center gap-1 px-2.5 py-2 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TooltipProvider :delay-duration="0">
<!-- In-flight SSE progress -->
<template v-if="inFlight">
<Loader2 :size="15" class="animate-spin text-primary shrink-0" />
<span class="px-2 text-sm font-medium text-foreground whitespace-nowrap">
{{ inFlight.label }} {{ inFlight.processed }} / {{ inFlight.total }}
</span>
</template>
<template v-else-if="hasCustomContent">
<slot name="content" :count="count" />
</template>
<template v-else-if="!confirmingDelete && !exportMenuOpen && !ratingMenuOpen && !fieldMenuOpen && !refreshConfirmOpen">
<span class="px-2.5 py-0.5 text-sm font-semibold tabular-nums whitespace-nowrap rounded-full bg-primary/10 text-primary">{{
count
}}</span>
<div :class="DIVIDER" />
<Tooltip v-if="canBulkActions">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
data-testid="action-bulk-set-status"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
:aria-label="t('components.selectionActionBar.setReadingStatus')"
>
<BookOpen :size="ICON_SIZE" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="center" class="w-48">
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="onSetStatus(opt.value)">
<component :is="STATUS_ICONS[opt.value]" :size="14" />
<span>{{ opt.label }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.setReadingStatus') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="canSetRating">
<TooltipTrigger as-child>
<button
data-testid="action-bulk-set-rating"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="ratingMenuOpen = true"
>
<Star :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.setRating') }}</TooltipContent>
</Tooltip>
<div v-if="canEditMetadata" :class="DIVIDER" />
<Tooltip v-if="canEditMetadata">
<TooltipTrigger as-child>
<button
data-testid="action-bulk-edit-metadata"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('edit')"
>
<Pencil :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.openMetadataEditor') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="canEditMetadata && !queryScoped">
<TooltipTrigger as-child>
<button
data-testid="action-edit-individually"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('edit-individually')"
>
<SquareArrowOutUpRight :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.editIndividually') }}</TooltipContent>
</Tooltip>
<div v-if="canBulkActions" :class="DIVIDER" />
<Tooltip>
<TooltipTrigger as-child>
<button
data-testid="action-add-to-collection"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('add-to-collection')"
>
<FolderPlus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.addToCollection') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="inCollection">
<TooltipTrigger as-child>
<button
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_DESTRUCTIVE : BTN_DISABLED]"
@click="emit('remove-from-collection')"
>
<FolderMinus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.removeFromCollection') }}</TooltipContent>
</Tooltip>
<div v-if="canShare" :class="DIVIDER" />
<Tooltip v-if="hasPermission('email_send')">
<TooltipTrigger as-child>
<button
data-testid="action-send-email"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('send')"
>
<Mail :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.sendViaEmail') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="canDownload">
<TooltipTrigger as-child>
<button
data-testid="action-download-files"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="exportMenuOpen = true"
>
<Download :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.downloadFilesZip') }}</TooltipContent>
</Tooltip>
<div v-if="canShowMoreMenu" :class="DIVIDER" />
<Tooltip v-if="canShowMoreMenu">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
data-testid="action-bulk-metadata-menu"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]"
:aria-label="t('components.selectionActionBar.moreActions')"
>
<MoreHorizontal :size="ICON_SIZE" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="center" class="w-56">
<template v-if="canEditMetadata">
<DropdownMenuItem data-testid="action-bulk-set-field" @click="openFieldEditor">
<SquarePen :size="14" />
<span>{{ t('components.selectionActionBar.setField') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem data-testid="action-bulk-refresh-metadata" @click="onRefreshMetadata">
<RefreshCw :size="14" />
<span>{{ t('components.selectionActionBar.refreshMetadata') }}</span>
</DropdownMenuItem>
<DropdownMenuItem data-testid="action-bulk-re-extract-cover" @click="onReExtractCover">
<ImageDown :size="14" />
<span>{{ t('components.selectionActionBar.reExtractCover') }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger data-testid="action-bulk-metadata-lock">
<Lock :size="14" />
<span>{{ t('components.selectionActionBar.metadataLock') }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem data-testid="action-bulk-lock-metadata" @click="lockAll">
<Lock :size="14" />
<span>{{ t('components.selectionActionBar.lockAll') }}</span>
</DropdownMenuItem>
<DropdownMenuItem data-testid="action-bulk-unlock-metadata" @click="unlockAll">
<Unlock :size="14" />
<span>{{ t('components.selectionActionBar.unlockAll') }}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</template>
<template v-if="canDownload">
<DropdownMenuSeparator v-if="canEditMetadata" />
<DropdownMenuItem data-testid="action-export-metadata" @click="emit('export-metadata')">
<FileSpreadsheet :size="14" />
<span>{{ t('components.selectionActionBar.exportMetadata') }}</span>
</DropdownMenuItem>
</template>
<template v-if="canMoveToLibrary">
<DropdownMenuSeparator />
<DropdownMenuItem data-testid="action-move-to-library" @click="onMoveToLibrary">
<FolderInput :size="14" />
<span>{{ t('book.move.action') }}</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</span>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.moreActions') }}</TooltipContent>
</Tooltip>
<div v-if="hasPermission('library_delete_books')" :class="DIVIDER" />
<Tooltip v-if="hasPermission('library_delete_books')">
<TooltipTrigger as-child>
<button
data-testid="action-delete"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_DESTRUCTIVE : BTN_DISABLED]"
@click="confirmingDelete = true"
>
<Trash2 :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.deleteSelected') }}</TooltipContent>
</Tooltip>
<div :class="DIVIDER" />
<Tooltip>
<TooltipTrigger as-child>
<button
:class="[BTN_ICON, 'text-muted-foreground hover:text-foreground hover:bg-muted']"
:aria-label="t('components.selectionActionBar.exitSelection')"
@click="emit('exit')"
>
<X :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.exitSelection') }}</TooltipContent>
</Tooltip>
</template>
<!-- Export scope picker -->
<template v-else-if="exportMenuOpen">
<span class="hidden sm:inline px-3 text-sm font-semibold text-foreground whitespace-nowrap">{{
t('components.selectionActionBar.downloadFilesZipLabel')
}}</span>
<div :class="DIVIDER" />
<button :class="BTN_TEXT_PRIMARY" @click="onExport('primary')">{{ t('components.selectionActionBar.primaryOnly') }}</button>
<button :class="BTN_TEXT_PRIMARY" @click="onExport('all')">{{ t('components.selectionActionBar.allFormats') }}</button>
<button :class="BTN_TEXT_PRIMARY" @click="onExport('audio')">{{ t('components.selectionActionBar.audioOnly') }}</button>
<div :class="DIVIDER" />
<button :class="BTN_TEXT_CANCEL" @click="exportMenuOpen = false">{{ t('common.cancel') }}</button>
</template>
<!-- Star rating picker -->
<template v-else-if="ratingMenuOpen">
<span class="hidden sm:inline px-3 text-sm font-semibold text-foreground whitespace-nowrap">{{
t('components.selectionActionBar.setRatingLabel')
}}</span>
<div :class="DIVIDER" />
<button v-for="n in [1, 2, 3, 4, 5]" :key="n" :class="BTN_TEXT_PRIMARY" @click="onSetRating(n)">{{ n }}</button>
<button :class="BTN_TEXT_CANCEL" @click="clearRating">{{ t('components.selectionActionBar.clear') }}</button>
<div :class="DIVIDER" />
<button :class="BTN_TEXT_CANCEL" @click="ratingMenuOpen = false">{{ t('common.cancel') }}</button>
</template>
<!-- Refresh metadata confirmation -->
<template v-else-if="refreshConfirmOpen">
<span class="px-3 text-sm font-semibold text-foreground whitespace-nowrap" data-testid="bulk-refresh-confirm">
{{ t('components.selectionActionBar.refreshConfirm', { count }) }}
</span>
<div :class="DIVIDER" />
<IgnoreCacheCheckbox v-model="refreshIgnoreCache" compact class="px-2" />
<div :class="DIVIDER" />
<button
ref="refreshConfirmButton"
type="button"
:class="BTN_TEXT_PRIMARY"
data-testid="bulk-refresh-confirm-action"
@click="confirmRefreshMetadata"
>
<RefreshCw :size="14" />
{{ t('components.selectionActionBar.refreshConfirmAction') }}
</button>
<button type="button" :class="BTN_TEXT_CANCEL" data-testid="bulk-refresh-cancel" @click="cancelRefreshMetadata">
{{ t('common.cancel') }}
</button>
</template>
<!-- Field editor -->
<template v-else-if="fieldMenuOpen">
<span class="hidden sm:inline px-3 text-sm font-semibold text-foreground whitespace-nowrap">{{
t('components.selectionActionBar.setFieldLabel')
}}</span>
<div :class="DIVIDER" />
<select
v-model="bulkField"
class="h-8 rounded-full border border-border bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option v-for="opt in BULK_EDITABLE_FIELD_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<InputWithSuggestions
v-if="typeaheadSearchFn"
v-model="fieldValue"
:search-fn="typeaheadSearchFn"
:placeholder="fieldValuePlaceholder"
:class="'h-8 min-w-28 rounded-full border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary'"
/>
<input
v-else
v-model="fieldValue"
:type="numericFieldSelected ? 'number' : 'text'"
class="h-8 min-w-28 rounded-full border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
:placeholder="fieldValuePlaceholder"
/>
<button
:disabled="!canApplyFieldValue"
:class="[BTN_TEXT_PRIMARY, !canApplyFieldValue && 'cursor-not-allowed opacity-40']"
@click="applyFieldEdit"
>
{{ t('components.selectionActionBar.apply') }}
</button>
<button :class="BTN_TEXT_CANCEL" @click="resetFieldEditor">{{ t('common.cancel') }}</button>
</template>
<!-- Delete confirmation -->
<template v-else>
<span class="px-3 text-sm font-semibold text-destructive whitespace-nowrap">
{{ t('components.selectionActionBar.deleteConfirm', { count }) }}
</span>
<template v-if="count > 50">
<input
v-model="deleteInput"
class="h-7 w-24 rounded-xs border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-destructive"
:placeholder="t('components.selectionActionBar.typeDelete')"
/>
</template>
<div :class="DIVIDER" />
<button
:disabled="!canConfirmDelete"
:class="[BTN_TEXT_DESTRUCTIVE, !canConfirmDelete && 'opacity-40 cursor-not-allowed']"
@click="onConfirmDelete"
>
{{ t('common.delete') }}
</button>
<button :class="BTN_TEXT_CANCEL" @click="cancelDelete">{{ t('common.cancel') }}</button>
</template>
</TooltipProvider>
</div>
</div>
</Transition>
</template>