improve dialogs, option to open image dialog in edit then delete (#951)

* fix: change Content-Disposition to inline for proper document display in attachments

* feat: overhaul how dialog system works, add delete to image dialog and add button to open image dialog on edit page

* chore: remove unneeded console log

* fix: ensure cleanup of dialog callbacks on unmount in BarcodeModal, CreateModal, and ImageDialog components
This commit is contained in:
Tonya
2025-08-23 19:22:33 +01:00
committed by GitHub
parent 6fcd10d796
commit 27e9eb2277
14 changed files with 336 additions and 109 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { DialogID } from "@/components/ui/dialog-provider/utils";
import { DialogID, type NoParamDialogIDs, type OptionalDialogIDs } from "@/components/ui/dialog-provider/utils";
import {
CommandDialog,
CommandInput,
@@ -15,7 +15,7 @@
export type QuickMenuAction =
| { text: string; href: string; type: "navigate" }
| { text: string; dialogId: DialogID; shortcut: string; type: "create" };
| { text: string; dialogId: NoParamDialogIDs | OptionalDialogIDs; shortcut: string; type: "create" };
const props = defineProps({
actions: {
@@ -40,7 +40,7 @@
const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key);
if (item) {
e.preventDefault();
openDialog(item.dialogId);
openDialog(item.dialogId as NoParamDialogIDs);
}
// if esc is pressed, close the dialog
if (e.key === 'Escape') {
@@ -61,7 +61,7 @@
@select="
e => {
e.preventDefault();
openDialog(create.dialogId);
openDialog(create.dialogId as NoParamDialogIDs);
}
"
>

View File

@@ -93,7 +93,7 @@
};
const handleButtonClick = () => {
openDialog(DialogID.ProductImport, { barcode: detectedBarcode.value });
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
};
const startScanner = async () => {

View File

@@ -149,7 +149,7 @@
const headers = defaultHeaders;
onMounted(() => {
registerOpenDialogCallback(DialogID.ProductImport, params => {
const cleanup = registerOpenDialogCallback(DialogID.ProductImport, params => {
selectedRow.value = -1;
searching.value = false;
errorMessage.value = null;
@@ -168,6 +168,8 @@
products.value = null;
}
});
onUnmounted(cleanup);
});
const api = useUserApi();
@@ -180,7 +182,9 @@
selectedRow.value < products.value.length
) {
const p = products.value![selectedRow.value];
openDialog(DialogID.CreateItem, { product: p });
openDialog(DialogID.CreateItem, {
params: { product: p },
});
}
}

View File

@@ -300,7 +300,7 @@
}
onMounted(() => {
registerOpenDialogCallback(DialogID.CreateItem, async params => {
const cleanup = registerOpenDialogCallback(DialogID.CreateItem, async params => {
// needed since URL will be cleared in the next step => ParentId Selection should stay though
subItemCreate.value = subItemCreateParam.value === "y";
let parentItemLocationId = null;
@@ -359,6 +359,8 @@
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
}
});
onUnmounted(cleanup);
});
async function create(close = true) {

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { buttonVariants, Button } from "@/components/ui/button";
import { useDialog } from "@/components/ui/dialog-provider";
import { DialogID } from "~/components/ui/dialog-provider/utils";
import { useConfirm } from "@/composables/use-confirm";
import { toast } from "@/components/ui/sonner";
import MdiClose from "~icons/mdi/close";
import MdiDownload from "~icons/mdi/download";
import MdiDelete from "~icons/mdi/delete";
const { t } = useI18n();
const confirm = useConfirm();
const { closeDialog, registerOpenDialogCallback } = useDialog();
const api = useUserApi();
const image = reactive<{
attachmentId: string;
itemId: string;
originalSrc: string;
originalType?: string;
thumbnailSrc?: string;
}>({
attachmentId: "",
itemId: "",
originalSrc: "",
});
onMounted(() => {
const cleanup = registerOpenDialogCallback(DialogID.ItemImage, params => {
image.attachmentId = params.attachmentId;
image.itemId = params.itemId;
if (params.type === "preloaded") {
image.originalSrc = params.originalSrc;
image.originalType = params.originalType;
image.thumbnailSrc = params.thumbnailSrc;
} else if (params.type === "attachment") {
image.originalSrc = api.authURL(`/items/${params.itemId}/attachments/${params.attachmentId}`);
image.originalType = params.mimeType;
image.thumbnailSrc = params.thumbnailId
? api.authURL(`/items/${params.itemId}/attachments/${params.thumbnailId}`)
: image.originalSrc;
}
});
onUnmounted(cleanup);
});
async function deleteAttachment() {
const confirmed = await confirm.open(t("items.delete_attachment_confirm"));
if (confirmed.isCanceled) {
return;
}
const { error } = await api.items.attachments.delete(image.itemId, image.attachmentId);
if (error) {
toast.error(t("items.toast.failed_delete_attachment"));
return;
}
closeDialog(DialogID.ItemImage, {
action: "delete",
id: image.attachmentId,
});
toast.success(t("items.toast.attachment_deleted"));
}
</script>
<template>
<Dialog :dialog-id="DialogID.ItemImage">
<DialogContent class="w-auto border-transparent bg-transparent p-0" disable-close>
<picture>
<source :srcset="image.originalSrc" :type="image.originalType" />
<img :src="image.thumbnailSrc" alt="attachment image" />
</picture>
<Button variant="destructive" size="icon" class="absolute right-[84px] top-1" @click="deleteAttachment">
<MdiDelete />
</Button>
<a :class="buttonVariants({ size: 'icon' })" :href="image.originalSrc" download class="absolute right-11 top-1">
<MdiDownload />
</a>
<Button
size="icon"
class="absolute right-1 top-1"
@click="
closeDialog(DialogID.ItemImage);
image.originalSrc = '';
"
>
<MdiClose />
</Button>
</DialogContent>
</Dialog>
</template>

View File

@@ -25,13 +25,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
class="fixed inset-0 z-[60] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-1/2 top-1/2 z-[60] grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)
"

View File

@@ -1,40 +1,59 @@
<!-- DialogProvider.vue -->
<script setup lang="ts">
import { ref, reactive, computed } from "vue";
import { provideDialogContext, type DialogID, type DialogParamsMap } from "./utils";
import { ref, reactive, computed } from 'vue';
import {
provideDialogContext,
type DialogID,
type DialogParamsMap,
} from './utils';
const activeDialog = ref<DialogID | null>(null);
const activeAlerts = reactive<string[]>([]);
const openDialogCallbacks = new Map<DialogID, (params: any) => void>();
// onClose for the currently-open dialog (only one dialog can be active)
let activeOnCloseCallback: ((result?: any) => void) | undefined;
const registerOpenDialogCallback = <T extends DialogID>(
dialogId: T,
callback: (params?: T extends keyof DialogParamsMap ? DialogParamsMap[T] : undefined) => void
) =>
{
) => {
openDialogCallbacks.set(dialogId, callback as (params: any) => void);
}
return () => {
openDialogCallbacks.delete(dialogId);
};
};
const openDialog = (dialogId: DialogID, params?: any) => {
const openDialog = <T extends DialogID>(dialogId: T, options?: any) => {
if (activeAlerts.length > 0) return;
activeDialog.value = dialogId;
activeOnCloseCallback = options?.onClose;
const openCallback = openDialogCallbacks.get(dialogId);
if (openCallback) {
openCallback(params);
openCallback(options?.params);
}
};
const closeDialog = (dialogId?: DialogID) => {
if (dialogId) {
function closeDialog(dialogId?: DialogID, result?: any) {
// No dialogId passed -> close current active dialog without result
if (!dialogId) {
if (activeDialog.value) {
// call onClose (if any) with no result
activeOnCloseCallback?.(undefined);
activeOnCloseCallback = undefined;
}
activeDialog.value = null;
return;
}
// dialogId passed -> if it's the active dialog, call onClose with result
if (activeDialog.value && activeDialog.value === dialogId) {
activeOnCloseCallback?.(result);
activeOnCloseCallback = undefined;
activeDialog.value = null;
}
} else {
activeDialog.value = null;
}
};
const addAlert = (alertId: string) => {
activeAlerts.push(alertId);
@@ -42,9 +61,7 @@
const removeAlert = (alertId: string) => {
const index = activeAlerts.indexOf(alertId);
if (index !== -1) {
activeAlerts.splice(index, 1);
}
if (index !== -1) activeAlerts.splice(index, 1);
};
// Provide context to child components

View File

@@ -1,7 +1,7 @@
import type { ComputedRef } from "vue";
import { createContext } from "reka-ui";
import { useMagicKeys, useActiveElement } from "@vueuse/core";
import type { BarcodeProduct } from "~~/lib/api/types/data-contracts";
import { computed, type ComputedRef } from 'vue';
import { createContext } from 'reka-ui';
import { useMagicKeys, useActiveElement } from '@vueuse/core';
import type { BarcodeProduct } from '~~/lib/api/types/data-contracts';
export enum DialogID {
AttachmentEdit = 'attachment-edit',
@@ -25,71 +25,164 @@ export enum DialogID {
UpdateLocation = 'update-location',
}
/**
* - Keys present without ? => params required
* - Keys present with ? => params optional
* - Keys not present => no params allowed
*/
export type DialogParamsMap = {
[DialogID.CreateItem]: { product?: BarcodeProduct };
[DialogID.ProductImport]: { barcode?: string };
[DialogID.ItemImage]:
| ({
type: 'preloaded';
originalSrc: string;
originalType?: string;
thumbnailSrc?: string;
}
| {
type: 'attachment';
mimeType: string;
thumbnailId?: string;
}) & {
itemId: string;
attachmentId: string;
};
[DialogID.CreateItem]?: { product?: BarcodeProduct };
[DialogID.ProductImport]?: { barcode?: string };
};
type DialogsWithParams = keyof DialogParamsMap;
/**
* Defines the payload type for a dialog's onClose callback.
*/
export type DialogResultMap = {
[DialogID.ItemImage]?: { action: 'delete', id: string };
};
/** Helpers to split IDs by requirement */
type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
type RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>;
type SpecifiedDialogIDs = keyof DialogParamsMap;
export type NoParamDialogIDs = Exclude<DialogID, SpecifiedDialogIDs>;
export type RequiredDialogIDs = RequiredKeys<DialogParamsMap>;
export type OptionalDialogIDs = OptionalKeys<DialogParamsMap>;
type ParamsOf<T extends DialogID> = T extends SpecifiedDialogIDs
? DialogParamsMap[T]
: never;
type ResultOf<T extends DialogID> = T extends keyof DialogResultMap
? DialogResultMap[T]
: void;
type OpenDialog = {
<T extends DialogID>(dialogId: T, params?: T extends DialogsWithParams ? DialogParamsMap[T] : undefined): void;
// Dialogs with no parameters
<T extends NoParamDialogIDs>(
dialogId: T,
options?: { onClose?: (result?: ResultOf<T>) => void; params?: never }
): void;
// Dialogs with required parameters
<T extends RequiredDialogIDs>(
dialogId: T,
options: { params: ParamsOf<T>; onClose?: (result?: ResultOf<T>) => void }
): void;
// Dialogs with optional parameters
<T extends OptionalDialogIDs>(
dialogId: T,
options?: { params?: ParamsOf<T>; onClose?: (result?: ResultOf<T>) => void }
): void;
};
type CloseDialog = {
// Close the currently active dialog, no ID specified. No result payload.
(): void;
// Close a specific dialog that has a defined result type.
<T extends keyof DialogResultMap>(dialogId: T, result?: ResultOf<T>): void;
// Close a specific dialog that has NO defined result type.
<T extends Exclude<DialogID, keyof DialogResultMap>>(
dialogId: T,
result?: never
): void;
};
type OpenCallback = {
<T extends DialogID>(dialogId: T, callback: (params?: T extends keyof DialogParamsMap ? DialogParamsMap[T] : undefined) => void): void;
}
<T extends NoParamDialogIDs>(dialogId: T, cb: () => void): () => void;
<T extends RequiredDialogIDs>(
dialogId: T,
cb: (params: ParamsOf<T>) => void
): () => void;
<T extends OptionalDialogIDs>(
dialogId: T,
cb: (params?: ParamsOf<T>) => void
): () => void;
};
export const [useDialog, provideDialogContext] = createContext<{
activeDialog: ComputedRef<DialogID | null>;
activeAlerts: ComputedRef<string[]>;
registerOpenDialogCallback: OpenCallback;
openDialog: OpenDialog;
closeDialog: (dialogId?: DialogID) => void;
closeDialog: CloseDialog;
addAlert: (alertId: string) => void;
removeAlert: (alertId: string) => void;
}>("DialogProvider");
}>('DialogProvider');
export const useDialogHotkey = (
dialogId: DialogID,
key: {
/**
* Hotkey helper:
* - No/optional params: pass dialogId + key
* - Required params: pass dialogId + key + getParams()
*/
type HotkeyKey = {
shift?: boolean;
ctrl?: boolean;
code: string;
}
) => {
};
export function useDialogHotkey<T extends NoParamDialogIDs | OptionalDialogIDs>(
dialogId: T,
key: HotkeyKey
): void;
export function useDialogHotkey<T extends RequiredDialogIDs>(
dialogId: T,
key: HotkeyKey,
getParams: () => ParamsOf<T>
): void;
export function useDialogHotkey(
dialogId: DialogID,
key: HotkeyKey,
getParams?: () => unknown
) {
const { openDialog } = useDialog();
const activeElement = useActiveElement();
const notUsingInput = computed(
() => activeElement.value?.tagName !== "INPUT" && activeElement.value?.tagName !== "TEXTAREA"
() =>
activeElement.value?.tagName !== 'INPUT' &&
activeElement.value?.tagName !== 'TEXTAREA'
);
useMagicKeys({
passive: false,
onEventFired: event => {
// console.log({
// event,
// notUsingInput: notUsingInput.value,
// eventType: event.type,
// keyCode: event.code,
// matchingKeyCode: key.code === event.code,
// shift: event.shiftKey,
// matchingShift: key.shift === undefined || event.shiftKey === key.shift,
// ctrl: event.ctrlKey,
// matchingCtrl: key.ctrl === undefined || event.ctrlKey === key.ctrl,
// });
onEventFired: (event) => {
if (
notUsingInput.value &&
event.type === "keydown" &&
event.type === 'keydown' &&
event.code === key.code &&
(key.shift === undefined || event.shiftKey === key.shift) &&
(key.ctrl === undefined || event.ctrlKey === key.ctrl)
) {
openDialog(dialogId);
if (getParams) {
openDialog(dialogId as RequiredDialogIDs, {
params: getParams() as never,
});
} else {
openDialog(dialogId as NoParamDialogIDs);
}
event.preventDefault();
}
},
});
};
}

View File

@@ -9,7 +9,7 @@
const isOpen = computed(() => (activeDialog.value && activeDialog.value === props.dialogId));
const onOpenChange = (open: boolean) => {
if (!open) closeDialog(props.dialogId);
if (!open) closeDialog(props.dialogId as any);
};
const forwarded = useForwardPropsEmits(props, emits);

View File

@@ -14,7 +14,7 @@
const isOpen = computed(() => activeDialog.value !== null && activeDialog.value === props.dialogId);
const onOpenChange = (open: boolean) => {
if (!open) closeDialog(props.dialogId);
if (!open) closeDialog(props.dialogId as any);
};
const forwarded = useForwardPropsEmits(props, emits);

View File

@@ -42,7 +42,7 @@
v-for="btn in dropdown"
:key="btn.id"
class="group cursor-pointer text-lg"
@click="openDialog(btn.dialogId)"
@click="openDialog(btn.dialogId as NoParamDialogIDs)"
>
{{ btn.name.value }}
<Shortcut
@@ -210,7 +210,7 @@
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { toast } from "@/components/ui/sonner";
import { DialogID } from "~/components/ui/dialog-provider/utils";
import { DialogID, type NoParamDialogIDs, type OptionalDialogIDs } from "~/components/ui/dialog-provider/utils";
const { t, locale } = useI18n();
const username = computed(() => authCtx.user?.name || "User");
@@ -269,7 +269,7 @@
id: number;
name: ComputedRef<string>;
shortcut: string;
dialogId: DialogID;
dialogId: NoParamDialogIDs | OptionalDialogIDs;
};
const dropdown: DropdownItem[] = [
@@ -343,7 +343,7 @@
const quickMenuActions = reactive([
...dropdown.map(v => ({
text: computed(() => v.name.value),
dialogId: v.dialogId,
dialogId: v.dialogId as NoParamDialogIDs,
shortcut: v.shortcut.split("+")[1],
type: "create" as const,
})),

View File

@@ -311,7 +311,8 @@
"primary_photo_sub": "This option is only available for photos. Only one photo can be primary. If you select this option, the current primary photo, if any will be unselected.",
"select_type": "Select a type",
"title": "Attachment Edit"
}
},
"view_image": "View Image"
},
"edit_details": "Edit Details",
"field_selector": "Field Selector",

View File

@@ -4,11 +4,9 @@
import type { AnyDetail, Detail, Details } from "~~/components/global/DetailsSection/types";
import { filterZeroValues } from "~~/components/global/DetailsSection/types";
import type { ItemAttachment } from "~~/lib/api/types/data-contracts";
import MdiClose from "~icons/mdi/close";
import MdiPackageVariant from "~icons/mdi/package-variant";
import MdiPlus from "~icons/mdi/plus";
import MdiMinus from "~icons/mdi/minus";
import MdiDownload from "~icons/mdi/download";
import MdiContentCopy from "~icons/mdi/content-copy";
import MdiDelete from "~icons/mdi/delete";
import { Separator } from "@/components/ui/separator";
@@ -19,8 +17,7 @@
BreadcrumbList,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button, ButtonGroup, buttonVariants } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Button, ButtonGroup } from "@/components/ui/button";
import { useDialog } from "@/components/ui/dialog-provider";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -29,7 +26,7 @@
const { t } = useI18n();
const { openDialog, closeDialog } = useDialog();
const { openDialog } = useDialog();
definePageMeta({
middleware: ["auth"],
@@ -109,6 +106,7 @@
type Photo = {
thumbnailSrc?: string;
originalSrc: string;
attachmentId: string;
originalType?: string;
};
@@ -122,6 +120,7 @@
const photo: Photo = {
originalSrc: api.authURL(`/items/${item.value!.id}/attachments/${cur.id}`),
originalType: cur.mimeType,
attachmentId: cur.id,
};
if (cur.thumbnail) {
photo.thumbnailSrc = api.authURL(`/items/${item.value!.id}/attachments/${cur.thumbnail.id}`);
@@ -406,19 +405,22 @@
return v;
});
const dialoged = reactive<Photo>({
originalSrc: "",
});
function openImageDialog(img: Photo) {
dialoged.originalSrc = img.originalSrc;
dialoged.originalType = img.originalType;
dialoged.thumbnailSrc = img.thumbnailSrc;
openDialog(DialogID.ItemImage);
function openImageDialog(img: Photo, itemId: string) {
openDialog(DialogID.ItemImage, {
params: {
type: "preloaded",
originalSrc: img.originalSrc,
originalType: img.originalType,
thumbnailSrc: img.thumbnailSrc,
attachmentId: img.attachmentId,
itemId,
},
onClose: result => {
if (result?.action === "delete") {
item.value!.attachments = item.value!.attachments.filter(a => a.id !== result.id);
}
function closeImageDialog() {
closeDialog(DialogID.ItemImage);
},
});
}
const currentUrl = computed(() => {
@@ -552,6 +554,7 @@
<!-- set page title -->
<Title>{{ item.name }}</Title>
<ItemImageDialog />
<Dialog :dialog-id="DialogID.DuplicateTemporarySettings">
<DialogContent>
<DialogHeader>
@@ -571,26 +574,6 @@
</DialogContent>
</Dialog>
<Dialog :dialog-id="DialogID.ItemImage">
<DialogContent class="w-auto border-transparent bg-transparent p-0" disable-close>
<picture>
<source :srcset="dialoged.originalSrc" :type="dialoged.originalType" />
<img :src="dialoged.thumbnailSrc" alt="attachement image" />
</picture>
<a
:class="buttonVariants({ size: 'icon' })"
:href="dialoged.originalSrc"
download
class="absolute right-11 top-1"
>
<MdiDownload />
</a>
<Button size="icon" class="absolute right-1 top-1" @click="closeImageDialog">
<MdiClose />
</Button>
</DialogContent>
</Dialog>
<section>
<Card class="p-3">
<header :class="{ 'mb-2': item.description }">
@@ -726,7 +709,7 @@
<BaseCard v-if="photos && photos.length > 0">
<template #title> {{ $t("items.photos") }} </template>
<div class="scroll-bg container mx-auto flex max-h-[500px] flex-wrap gap-2 overflow-y-scroll border-t p-4">
<button v-for="(img, i) in photos" :key="i" @click="openImageDialog(img)">
<button v-for="(img, i) in photos" :key="i" @click="openImageDialog(img, item.id)">
<picture>
<source :srcset="img.originalSrc" :type="img.originalType" />
<img class="max-h-[200px] rounded" :src="img.thumbnailSrc" alt="attachment image" />

View File

@@ -9,6 +9,7 @@
import MdiDelete from "~icons/mdi/delete";
import MdiPencil from "~icons/mdi/pencil";
import MdiContentSaveOutline from "~icons/mdi/content-save-outline";
import MdiImageOutline from "~icons/mdi/image-outline";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useDialog } from "@/components/ui/dialog-provider";
@@ -697,6 +698,33 @@
{{ $t(`items.${attachment.type}`) }}
</p>
<div class="flex justify-end gap-2">
<Tooltip v-if="attachment.type === 'photo'">
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
@click="
openDialog(DialogID.ItemImage, {
params: {
type: 'attachment',
itemId: item.id,
attachmentId: attachment.id,
thumbnailId: attachment.thumbnail?.id,
mimeType: attachment.mimeType,
},
onClose: result => {
if (result?.action === 'delete') {
item.attachments = item.attachments.filter(a => a.id !== result.id);
}
},
})
"
>
<MdiImageOutline />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t("items.edit.view_image") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="destructive" size="icon" @click="deleteAttachment(attachment.id)">