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

View File

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

View File

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

View File

@@ -300,7 +300,7 @@
} }
onMounted(() => { 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 // needed since URL will be cleared in the next step => ParentId Selection should stay though
subItemCreate.value = subItemCreateParam.value === "y"; subItemCreate.value = subItemCreateParam.value === "y";
let parentItemLocationId = null; let parentItemLocationId = null;
@@ -359,6 +359,8 @@
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id); form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
} }
}); });
onUnmounted(cleanup);
}); });
async function create(close = true) { 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> <template>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay <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 <AlertDialogContent
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( 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, props.class,
) )
" "

View File

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

View File

@@ -1,7 +1,7 @@
import type { ComputedRef } from "vue"; import { computed, type ComputedRef } from 'vue';
import { createContext } from "reka-ui"; import { createContext } from 'reka-ui';
import { useMagicKeys, useActiveElement } from "@vueuse/core"; import { useMagicKeys, useActiveElement } from '@vueuse/core';
import type { BarcodeProduct } from "~~/lib/api/types/data-contracts"; import type { BarcodeProduct } from '~~/lib/api/types/data-contracts';
export enum DialogID { export enum DialogID {
AttachmentEdit = 'attachment-edit', AttachmentEdit = 'attachment-edit',
@@ -25,71 +25,164 @@ export enum DialogID {
UpdateLocation = 'update-location', UpdateLocation = 'update-location',
} }
/**
* - Keys present without ? => params required
* - Keys present with ? => params optional
* - Keys not present => no params allowed
*/
export type DialogParamsMap = { export type DialogParamsMap = {
[DialogID.CreateItem]: { product?: BarcodeProduct }; [DialogID.ItemImage]:
[DialogID.ProductImport]: { barcode?: string }; | ({
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 = { 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 = { 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<{ export const [useDialog, provideDialogContext] = createContext<{
activeDialog: ComputedRef<DialogID | null>; activeDialog: ComputedRef<DialogID | null>;
activeAlerts: ComputedRef<string[]>; activeAlerts: ComputedRef<string[]>;
registerOpenDialogCallback: OpenCallback; registerOpenDialogCallback: OpenCallback;
openDialog: OpenDialog; openDialog: OpenDialog;
closeDialog: (dialogId?: DialogID) => void; closeDialog: CloseDialog;
addAlert: (alertId: string) => void; addAlert: (alertId: string) => void;
removeAlert: (alertId: string) => void; removeAlert: (alertId: string) => void;
}>("DialogProvider"); }>('DialogProvider');
export const useDialogHotkey = ( /**
* 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, dialogId: DialogID,
key: { key: HotkeyKey,
shift?: boolean; getParams?: () => unknown
ctrl?: boolean; ) {
code: string;
}
) => {
const { openDialog } = useDialog(); const { openDialog } = useDialog();
const activeElement = useActiveElement(); const activeElement = useActiveElement();
const notUsingInput = computed( const notUsingInput = computed(
() => activeElement.value?.tagName !== "INPUT" && activeElement.value?.tagName !== "TEXTAREA" () =>
activeElement.value?.tagName !== 'INPUT' &&
activeElement.value?.tagName !== 'TEXTAREA'
); );
useMagicKeys({ useMagicKeys({
passive: false, passive: false,
onEventFired: event => { 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,
// });
if ( if (
notUsingInput.value && notUsingInput.value &&
event.type === "keydown" && event.type === 'keydown' &&
event.code === key.code && event.code === key.code &&
(key.shift === undefined || event.shiftKey === key.shift) && (key.shift === undefined || event.shiftKey === key.shift) &&
(key.ctrl === undefined || event.ctrlKey === key.ctrl) (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(); event.preventDefault();
} }
}, },
}); });
}; }

View File

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

View File

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

View File

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

View File

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