mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
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:
@@ -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);
|
||||
}
|
||||
"
|
||||
>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
openDialog(DialogID.ProductImport, { barcode: detectedBarcode.value });
|
||||
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
|
||||
};
|
||||
|
||||
const startScanner = async () => {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
99
frontend/components/Item/ImageDialog.vue
Normal file
99
frontend/components/Item/ImageDialog.vue
Normal 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>
|
||||
@@ -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,
|
||||
)
|
||||
"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)">
|
||||
|
||||
Reference in New Issue
Block a user